summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.txt11
-rw-r--r--chroot_test.txt94
-rwxr-xr-xlex.cgi142
3 files changed, 205 insertions, 42 deletions
diff --git a/README.txt b/README.txt
index 9964765..d01e545 100644
--- a/README.txt
+++ b/README.txt
@@ -7,6 +7,17 @@ TEST
# slowcgi -d -v -s /var/www/run/slowcgi_debug.sock
+Without PGP:
+
+# echo -n "body-mime=hello%20world" | chroot -u www /var/www /usr/bin/perl -e '
+ $ENV{CONTENT_TYPE} = "application/x-www-form-urlencoded";
+ $ENV{CONTENT_LENGTH} = 23;
+ $ENV{REMOTE_ADDR} = "127.0.0.1";
+ $ENV{HTTP_USER_AGENT}= "test-cli";
+ do "/cgi-bin/lex.cgi";
+ print $@ if $@;
+'
+
CHROOT:
# Create the jail root
diff --git a/chroot_test.txt b/chroot_test.txt
new file mode 100644
index 0000000..a1c76d8
--- /dev/null
+++ b/chroot_test.txt
@@ -0,0 +1,94 @@
+chroot -u www /var/www /usr/bin/perl -e '
+ use URI::Escape qw(uri_escape);
+
+ my $raw_mime = <<'\''MIME_END'\'';
+Received: from mailtransmit04.runbox.com (mailtransmit04.runbox.com [185.226.149.37]) by
+ 99abfb82813db16bb2b923f8a789c1622dfbc9adec15037074ea0a1d0bd3d9be with SMTP id
+ <undefined> (version=TLS1.3, cipher=TLS_AES_128_GCM_SHA256); Mon, 10 Aug 2026
+ 01:23:51 GMT
+Authentication-Results: mxa.mailgun.org;
+ dkim=pass header.d=asciimx.com header.s=selector2 header.b=n9CTxyV+;
+ spf=pass (domain asciimx.com designates 185.226.149.37 as permitted sender) smtp.mailfrom="sadeep@asciimx.com";
+ spf=pass (domain mailtransmit04.runbox.com designates 185.226.149.37 as permitted sender) smtp.helo="mailtransmit04.runbox.com";
+ dmarc=none header.from=asciimx.com
+X-Mailgun-Incoming: Yes
+X-Envelope-From: sadeep@asciimx.com
+Received: from mailtransmit03.runbox ([10.9.9.163] helo=aibo.runbox.com)
+ by mailtransmit04.runbox.com with esmtps (TLS1.2) tls TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
+ (Exim 4.93)
+ (envelope-from <sadeep@asciimx.com>)
+ id 1wtEk1-00AOZb-03
+ for lex@info.asciimx.com; Mon, 10 Aug 2026 03:23:49 +0200
+DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed; d=asciimx.com
+ ; s=selector2; h=Content-Type:MIME-Version:Message-ID:Subject:To:From:Date;
+ bh=LcStAB7n3m8zTsu0AENI8MHLDQClt2p0YtEX7Mj98uo=; b=n9CTxyV+0K9gtkqLjdbPw57eJ
+ l5i4DmiJCsO4wKHeGsvNK8YQ7q1dqnW205gpNXTK4eC0yXI7fT2t0OmOTJROVr7MOyen0ZdR0J+VL
+ PEWlfqNkSzlnonzrREOGlCrZYnukxaPxGDUKEZ1AG+isCnzUt29R540rtQo/mi9v9sBG0/BbVWew8
+ tkT9BGoRx351kTaDVw7VAwc6FLi4Cs06XuhxFOYrGGHXY9NyFlGjAdTuLxa2ObVibBvy+MklzHe7S
+ eUArWBPBVyCgU3ejhVbqfDfIZ+pl12iSYuF4luF2lwMAOOmY2+/i49ktjqHhl+632UJhPYQRZkPxe
+ OuoomaU9g==;
+Received: from [10.9.9.74] (helo=submission03.runbox)
+ by mailtransmit03.runbox with esmtp (Exim 4.86_2)
+ (envelope-from <sadeep@asciimx.com>)
+ id 1wtEk0-0001MT-FI
+ for lex@info.asciimx.com; Mon, 10 Aug 2026 03:23:48 +0200
+Received: by submission03.runbox with esmtpsa [Authenticated ID (1110424)] (TLS1.2:ECDHE_SECP256R1__RSA_PSS_RSAE_SHA512__AES_256_GCM:256)
+ (Exim 4.95)
+ id 1wtEjg-009saV-DO
+ for lex@info.asciimx.com;
+ Mon, 10 Aug 2026 03:23:28 +0200
+Date: Mon, 10 Aug 2026 09:23:24 +0800
+From: Sadeep <sadeep@asciimx.com>
+To: lex@info.asciimx.com
+Subject: Test
+Message-ID: <ankoDK-hx0-riv1A@wd>
+MIME-Version: 1.0
+Content-Type: multipart/signed; micalg=pgp-sha256;
+ protocol="application/pgp-signature"; boundary="+lN9vFx72/4iTYkI"
+Content-Disposition: inline
+
+
+--+lN9vFx72/4iTYkI
+Content-Type: text/plain; charset=us-ascii
+Content-Disposition: inline
+Content-Transfer-Encoding: quoted-printable
+
+This is a test email.
+
+--
+Sadeep
+
+--+lN9vFx72/4iTYkI
+Content-Type: application/pgp-signature; name=signature.asc
+
+-----BEGIN PGP SIGNATURE-----
+
+iQHPBAEBCAA5FiEEqfBXeZvaBlev9/CoEDv54+dQv34FAmp5KAYbFIAAAAAABAAO
+bWFudTIsMi41KzEuMTIsMiwzAAoJEBA7+ePnUL9+LAwMAM42Hp1A5YLHqOVSL716
+Ag42MCRldlJUklPUMonzrTxbRwoReG9yGqrxuB6VPDZ4pWwzpupivaUhi2ULw9Ru
+fAc8xoKZQTsjB1bo7BXw0jXwYHUCLv6HkMWgL6nJsKvcSJzIRP9arTyu4ZEqH/vI
+Io6IZIiWGwaMM+NPK1HjFbcjkKenHcapmT14aEsUQ4QjUlyyFOvvzKDZ7fgNmh/o
+hRHdXkivYmlww6dIR49bwH2pIRu3SKXtq0DqMF2t0KjjNZ0ledSUKG7P1+U7GIY2
+W0fTyK9Bf/kB8fLwCKUDQbytw4qTgHMtvE6v+h/3VUK/Lt9JEwjySTfVzrHvwNRG
+XycQXlM/5sc/tbpm0fVc5K+yg2OauOrSUI9r4HGvNS/FawV5rTuSi7KNDLB6G8O6
+GKugMgDMJnanBRI2U7quKNmpf7s95ouFN8vJ84ZslE4bzrtjDE0IQ1FOsO1yW64Y
+FE51iC4OY0un/HsYLSsFPHlebd/52WrQikVljQNDgvr/Vg==
+=GOo2
+-----END PGP SIGNATURE-----
+
+--+lN9vFx72/4iTYkI--
+MIME_END
+
+ my $payload = "body-mime=" . uri_escape($raw_mime);
+
+ $ENV{CONTENT_TYPE} = "application/x-www-form-urlencoded";
+ $ENV{CONTENT_LENGTH} = length($payload);
+ $ENV{REMOTE_ADDR} = "35.212.14.65";
+ $ENV{HTTP_USER_AGENT}= "Go-http-client/2.0";
+
+ open(my $fh, "|-", "/usr/bin/perl", "/cgi-bin/lex.cgi") or die "Cannot pipe to lex.cgi: $!";
+ print $fh $payload;
+ close($fh);
+
+ print "Exit status: " . ($? >> 8) . "\n";
+'
diff --git a/lex.cgi b/lex.cgi
index f6fb12d..e4a1994 100755
--- a/lex.cgi
+++ b/lex.cgi
@@ -8,12 +8,18 @@ use Email::MIME;
use OpenBSD::Pledge;
use OpenBSD::Unveil;
-unveil("/tmp", "rwc") or die "unveil /tmp failed: $!";
-unveil() or die "unveil lock failed: $!";
+my $base_dir = "/var/lex";
+my $keyring_file = "$base_dir/pubring.gpg";
-pledge('stdio rpath wpath cpath') or die "pledge failed: $!";
+unveil("/usr/local/bin/gpg", "rx") or die "unveil gpg failed: $!";
+unveil("/dev/null", "rw") or die "unveil /dev/null failed: $!";
+unveil("/tmp", "rwc") or die "unveil /tmp failed: $!";
+unveil($base_dir, "rwc") or die "unveil $base_dir failed: $!";
+unveil() or die "unveil lock failed: $!";
-my $log_file = "/tmp/lex.log";
+pledge('stdio rpath wpath cpath proc exec') or die "pledge failed: $!";
+
+my $log_file = "$base_dir/debug.log";
my $max_log_bytes = 1024 * 1024; # 1 MB cap
sub log_msg {
@@ -28,7 +34,13 @@ sub log_msg {
my $lh;
if (open($lh, '>>', $log_file)) {
- my $timestamp = sprintf("%d", time());
+ my $tz_offset = 8 * 3600;
+ my ($sec, $min, $hour, $mday, $mon, $year) = gmtime(time() + $tz_offset);
+
+ my $timestamp = sprintf(
+ "%04d-%02d-%02d %02d:%02d:%02d",
+ $year + 1900, $mon + 1, $mday, $hour, $min, $sec
+ );
print $lh "[$timestamp] [$$] $msg\n";
close($lh);
}
@@ -41,10 +53,84 @@ sub respond_ok {
exit 0;
}
-my $content_type = $ENV{'CONTENT_TYPE'} // '';
+sub verify_pgp_signature {
+ my ($signed_data, $signature) = @_;
+
+ unless (-f $keyring_file) {
+ log_msg("ERROR: Keyring missing at $keyring_file");
+ return 0;
+ }
+
+ my $tmp_dir = "/tmp/lex";
+ unless (-d $tmp_dir) {
+ make_path($tmp_dir);
+ }
+
+ my $msg_id = sprintf("msg_%.6f_$$", time());
+ my $tmp_data = "${tmp_dir}/${msg_id}.mime";
+ my $tmp_sig = "${tmp_dir}/${msg_id}.sig";
+
+ # Ensure CRLF line endings on the signed data block
+ $signed_data =~ s/\r?\n/\r\n/g;
+
+ my $dh;
+ unless (open($dh, '>:raw', $tmp_data)) {
+ log_msg("ERROR: Could not create temp data file: $!");
+ return 0;
+ }
+ print $dh $signed_data;
+ close($dh);
+
+ # Write sig file with trailing CRLF
+ my $sh;
+ unless (open($sh, '>:raw', $tmp_sig)) {
+ log_msg("ERROR: Could not create temp sig file: $!");
+ unlink($tmp_data);
+ return 0;
+ }
+ print $sh $signature . "\r\n";
+ close($sh);
+
+ my $pipe;
+ my $gpg_output = "";
+ my $pid = open($pipe, "-|") // die "Cannot fork: $!";
+
+ if ($pid == 0) {
+ open(STDERR, '>&', STDOUT);
+ exec(
+ '/usr/local/bin/gpg',
+ '--batch',
+ '--no-default-keyring',
+ '--keyring', $keyring_file,
+ '--trust-model', 'always',
+ '--verify',
+ $tmp_sig,
+ $tmp_data
+ ) or exit(127);
+ }
+
+ while (my $line = <$pipe>) {
+ $gpg_output .= $line;
+ }
+ close($pipe);
+ my $exit_code = $? >> 8;
+
+ unlink($tmp_data, $tmp_sig);
+
+ if ($exit_code == 0) {
+ return 1;
+ } else {
+ $gpg_output =~ s/\r?\n/ /g;
+ log_msg("DEBUG: gpg output: $gpg_output");
+ log_msg("DEBUG: gpg exited with code $exit_code");
+ return 0;
+ }
+}
+
+my $content_type = $ENV{'CONTENT_TYPE'} // '';
my $content_length = $ENV{'CONTENT_LENGTH'} // 0;
-my $remote_ip = $ENV{'REMOTE_ADDR'} // 'unknown';
+my $remote_ip = $ENV{'REMOTE_ADDR'} // 'unknown';
my $user_agent = $ENV{'HTTP_USER_AGENT'} // 'unknown';
log_msg("INFO: Request received from $remote_ip [$user_agent]");
@@ -57,7 +143,6 @@ binmode(STDIN);
my $raw_data = '';
read(STDIN, $raw_data, $content_length);
-# Parse application/x-www-form-urlencoded payload
unless ($content_type =~ /application\/x-www-form-urlencoded/i) {
log_msg("WARN: Content-Type is not urlencoded: '$content_type'");
respond_ok();
@@ -78,7 +163,6 @@ for my $pair (split(/&/, $raw_data)) {
$params{$key} //= $val;
}
-# Extract MIME message for PGP check
my $mime_raw = $params{'body-mime'} // '';
if ($mime_raw eq '') {
log_msg("WARN: 'body-mime' parameter is empty or missing");
@@ -89,19 +173,15 @@ if ($mime_raw eq '') {
my $parsed = Email::MIME->new($mime_raw);
my @parts = $parsed->parts;
-# Signed content + PGP signature must be present
unless (@parts >= 2) {
log_msg("WARN: MIME parts count < 2 (found " . scalar(@parts) . ")");
respond_ok();
}
-# Part 0: canonical signed content (headers + body)
+# Extract canonical signed part directly (Part 0 string representation)
my $signed_part = $parts[0]->as_string;
-# Normalize line endings to \r\n for canonical PGP verification
-$signed_part =~ s/\r?\n/\r\n/g;
-
-# Part 1: detached PGP signature
+# Extract PGP signature block
my $sig_part_raw = $parts[1]->body_raw // '';
my ($pgp_sig) = $sig_part_raw =~ /(-----BEGIN PGP SIGNATURE-----[\s\S]*?-----END PGP SIGNATURE-----)/;
@@ -115,34 +195,11 @@ unless (length($signed_part) > 0 && $pgp_sig) {
respond_ok();
}
-my $dir = "/tmp/lex";
-unless (-d $dir) {
- make_path($dir);
-}
-
-my $msg_id = sprintf("msg_%.6f_$$", time());
-my $sig_file = "${dir}/${msg_id}.sig";
-my $mime_file = "${dir}/${msg_id}.mime";
-
-# Write MIME file
-my $mfh;
-unless (open($mfh, '>:raw', $mime_file)) {
- log_msg("ERROR: Failed to write $mime_file: $!");
- respond_ok();
-}
-print $mfh $signed_part;
-close($mfh);
-
-# Write sig file
-my $sfh;
-unless (open($sfh, '>:raw', $sig_file)) {
- log_msg("ERROR: Failed to write $sig_file: $!");
+unless (verify_pgp_signature($signed_part, $pgp_sig)) {
+ log_msg("WARN: PGP signature verification failed");
respond_ok();
}
-print $sfh $pgp_sig . "\r\n";
-close($sfh);
-# Extract body, strip signature block, leading and trailing whitespaces
my $plain_text = $parts[0]->body_str // '';
$plain_text =~ s/\r\n/\n/g;
$plain_text =~ s/\n-- \n.*$//s;
@@ -153,8 +210,7 @@ unless (length($plain_text) > 0) {
respond_ok();
}
-# Append plain text to queue file
-my $queue_file = "/tmp/lex_queue.txt";
+my $queue_file = "$base_dir/queue.txt";
my $qfh;
unless (open($qfh, '>>:utf8', $queue_file)) {
log_msg("ERROR: Failed to append to $queue_file: $!");
@@ -163,6 +219,8 @@ unless (open($qfh, '>>:utf8', $queue_file)) {
print $qfh $plain_text . "\n";
close($qfh);
+my $msg_id = sprintf("msg_%.6f_$$", time());
my $content_length_kb = sprintf("%.2f KB", $content_length / 1024);
log_msg("INFO: Processed $msg_id successfully ($content_length_kb)");
respond_ok();
+