From 7f9b62ea66b7e54387a99d44fa681b464d3e5d44 Mon Sep 17 00:00:00 2001 From: Andreas Erhard Date: Tue, 6 Oct 2026 09:04:04 +0200 Subject: [PATCH 1/2] Add option for more relaxed barelf handling and changed softdeny to deny as this is a permanent error --- CHANGELOG.md | 8 ++++++++ MANIFEST | 1 + config.sample/barelf | 5 +++++ docs/config.md | 14 ++++++++++++++ lib/Qpsmtpd/SMTP.pm | 32 +++++++++++++++++++++++++++++-- t/qpsmtpd-smtp.t | 45 +++++++++++++++++++++++++++++++++++++++++--- 6 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 config.sample/barelf diff --git a/CHANGELOG.md b/CHANGELOG.md index 02ff0ceb..561eb136 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - qpsmtpd.service, a systemd unit for qpsmtpd-forkserver, now the recommended way to run qpsmtpd +- config/barelf: `strict` (default), `tolerant` or `lazy` handling of a + bare LF/CR in the message data + +### Changed + +- a bare LF/CR in the message data is rejected with a permanent + `554 (#5.6.0)` instead of a temporary 421, so senders bounce at once + instead of retrying a message that can never pass ## [1.02] - 2026-10-01 diff --git a/MANIFEST b/MANIFEST index 14403039..c5bebe0a 100644 --- a/MANIFEST +++ b/MANIFEST @@ -6,6 +6,7 @@ config.sample/add_geoip_headers config.sample/badhelo config.sample/badmailfrom config.sample/badrcptto +config.sample/barelf config.sample/dkim/dkim_key_gen.sh config.sample/dnsbl_allow config.sample/databytes diff --git a/config.sample/barelf b/config.sample/barelf new file mode 100644 index 00000000..3d90a83f --- /dev/null +++ b/config.sample/barelf @@ -0,0 +1,5 @@ +# How a bare LF/CR in the message data is handled, see docs/config.md. +# strict - reject any line not ending in CRLF (default) +# tolerant - accept bare LF lines, but only . ends the data +# lazy - only check the final dot, as before 1.02 (allows SMTP smuggling) +strict diff --git a/docs/config.md b/docs/config.md index 103be823..ff67eefa 100644 --- a/docs/config.md +++ b/docs/config.md @@ -60,6 +60,20 @@ are shown below in ["Plugin settings"](#plugin-settings). size. Should be something less than the backend MTA has set as it's maximum message size (if there is one). +- barelf + + How a line in the message data that does not end in CRLF (a bare LF or + CR, RFC 5321 2.3.8) is handled. Such a message is rejected with + `554 (#5.6.0)` and the connection is closed. Defaults to `strict`. + + - `strict`: reject any line not ending in CRLF. + - `tolerant`: accept bare LF lines, but the data only ends at + `.`. A bare LF/CR on the final dot, or a final dot right + after a bare LF, is rejected. + - `lazy`: only reject a bare LF/CR on the final dot, as qpsmtpd did before + 1.02. __NOTE:__ this accepts `.` as the end of the data, which + allows SMTP smuggling. Prefer `tolerant`. + - size\_threshold When a message is greater than the size given in this config file, it will be diff --git a/lib/Qpsmtpd/SMTP.pm b/lib/Qpsmtpd/SMTP.pm index 75ed7ddf..f8edbb7b 100644 --- a/lib/Qpsmtpd/SMTP.pm +++ b/lib/Qpsmtpd/SMTP.pm @@ -675,6 +675,21 @@ sub command_line_too_long { return 1; } +# How a bare LF/CR in DATA is handled, from the 'barelf' config: +# strict - reject any line not ending in CRLF (default) +# tolerant - accept bare LF lines, but only . ends the data +# lazy - only reject a bare LF/CR on the final dot (pre 1.02 behaviour) +sub barelf_mode { + my $self = shift; + my ($mode) = $self->config('barelf'); + return 'strict' if !defined $mode; + $mode = lc $mode; + $mode =~ s/^\s+|\s+$//g; + return $mode if $mode =~ /^(?:strict|tolerant|lazy)$/; + $self->log(LOGWARN, "unknown barelf mode '$mode', using strict"); + return 'strict'; +} + sub disconnect { my $self = shift; $self->run_hooks("disconnect"); @@ -736,13 +751,26 @@ sub data_respond { my $header = Mail::Header->new(Modify => 0, MailFrom => 'COERCE'); my $timeout = $self->config('timeout'); + my $barelf = $self->barelf_mode; + my $prev_bare = 0; while (defined($_ = $self->getline($timeout))) { - if ($_ !~ /\r\n$/) { - $self->respond(421, 'See http://smtpd.develooper.com/barelf.html'); + # A bare LF/CR is a permanent defect of the message, a retry would + # fail the same way, so reject with a 5xx (RFC 5321 2.3.8). The + # 'barelf' config relaxes this, see docs/config.md. + my $bare = $_ !~ /\r\n$/; + my $bare_dot = $_ eq ".\n" || $_ eq ".\r"; + my $reject + = $barelf eq 'lazy' ? $bare_dot + : $barelf eq 'tolerant' ? $bare_dot || ($prev_bare && $_ eq ".\r\n") + : $bare; + if ($reject) { + $self->respond(554, + 'Bare LF in message data not permitted, see RFC 5321 2.3.8 (#5.6.0)'); $self->disconnect; return 1; } + $prev_bare = $bare; if ($_ eq ".\r\n") { $complete++; diff --git a/t/qpsmtpd-smtp.t b/t/qpsmtpd-smtp.t index 70b42fe8..93659b6c 100644 --- a/t/qpsmtpd-smtp.t +++ b/t/qpsmtpd-smtp.t @@ -360,17 +360,56 @@ sub __data_respond_barelf { }; my $code = $drive->("From: a\@example.com\r\n", "Date: now\r\n", "\r\n", "body\r\n", ".\r\n"); - isnt( $code, 421, 'well-formed message is not rejected as bare-LF' ); + isnt( $code, 554, 'well-formed message is not rejected as bare-LF' ); isnt( $smtpd->connection->notes('disconnected'), 1, 'well-formed message does not disconnect' ); $code = $drive->("From: a\@example.com\r\n", "bare\n", ".\r\n"); - is( $code, 421, 'bare LF in body is rejected' ); + is( $code, 554, 'bare LF in body is rejected' ); is( $smtpd->connection->notes('disconnected'), 1, 'bare LF in body disconnects' ); $code = $drive->("From: a\@example.com\r\n", "\r\n", "body\r\n", ".\n"); - is( $code, 421, 'bare LF terminator is rejected' ); + is( $code, 554, 'bare LF terminator is rejected' ); is( $smtpd->connection->notes('disconnected'), 1, 'bare LF terminator disconnects' ); + + # the 'barelf' config relaxes the check + my $orig_config = Test::Qpsmtpd->can('config'); + my $with_mode = sub { + my ($mode, @lines) = @_; + no warnings 'redefine'; + local *Test::Qpsmtpd::config = sub { + my ($self, $key) = @_; + return ($mode) if $key eq 'barelf'; + return $orig_config->(@_); + }; + my $c = $drive->(@lines); + return ($c, $smtpd->connection->notes('disconnected')); + }; + my @body = ("From: a\@example.com\r\n", "\r\n", "bare\n", "body\r\n", ".\r\n"); + my @bare_dot = ("From: a\@example.com\r\n", "\r\n", "body\r\n", ".\n"); + my @smuggle = ("From: a\@example.com\r\n", "\r\n", "body\n", ".\r\n"); + my $disc; + + ($code) = $with_mode->('strict', @body); + is( $code, 554, 'strict: bare LF in body is rejected' ); + ($code) = $with_mode->('bogus', @body); + is( $code, 554, 'unknown mode falls back to strict' ); + + ($code, $disc) = $with_mode->('tolerant', @body); + isnt( $code, 554, 'tolerant: bare LF in body is accepted' ); + isnt( $disc, 1, 'tolerant: bare LF in body does not disconnect' ); + ($code) = $with_mode->('tolerant', @bare_dot); + is( $code, 554, 'tolerant: bare LF terminator is rejected' ); + ($code) = $with_mode->('tolerant', @smuggle); + is( $code, 554, 'tolerant: . is rejected (SMTP smuggling)' ); + + ($code, $disc) = $with_mode->('lazy', @body); + isnt( $code, 554, 'lazy: bare LF in body is accepted' ); + isnt( $disc, 1, 'lazy: bare LF in body does not disconnect' ); + ($code) = $with_mode->('lazy', @bare_dot); + is( $code, 554, 'lazy: bare LF terminator is rejected' ); + ($code) = $with_mode->('lazy', @smuggle); + isnt( $code, 554, 'lazy: . ends the data, as before 1.02' ); } sub __clean_authentication_results { From 31a3f70666dd9900f19424427f93f765a94a7b27 Mon Sep 17 00:00:00 2001 From: Andreas Erhard Date: Tue, 6 Oct 2026 21:22:55 +0200 Subject: [PATCH 2/2] removed lazy as it can be a security issue --- CHANGELOG.md | 5 +++-- config.sample/barelf | 1 - docs/config.md | 7 ++++--- lib/Qpsmtpd/SMTP.pm | 15 +++++++++------ t/qpsmtpd-smtp.t | 8 -------- 5 files changed, 16 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a20c969..b57867e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,9 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - qpsmtpd.service, a systemd unit for qpsmtpd-forkserver, now the recommended way to run qpsmtpd -- config/barelf: `strict` (default), `tolerant` or `lazy` handling of a - bare LF/CR in the message data +- config/barelf: `strict` (default) or `tolerant` handling of a bare LF + in the message data. Both only accept `.` as the end of the + data, so SMTP smuggling stays rejected ### Changed diff --git a/config.sample/barelf b/config.sample/barelf index 3d90a83f..b245f18c 100644 --- a/config.sample/barelf +++ b/config.sample/barelf @@ -1,5 +1,4 @@ # How a bare LF/CR in the message data is handled, see docs/config.md. # strict - reject any line not ending in CRLF (default) # tolerant - accept bare LF lines, but only . ends the data -# lazy - only check the final dot, as before 1.02 (allows SMTP smuggling) strict diff --git a/docs/config.md b/docs/config.md index ff67eefa..4004c9dd 100644 --- a/docs/config.md +++ b/docs/config.md @@ -70,9 +70,10 @@ are shown below in ["Plugin settings"](#plugin-settings). - `tolerant`: accept bare LF lines, but the data only ends at `.`. A bare LF/CR on the final dot, or a final dot right after a bare LF, is rejected. - - `lazy`: only reject a bare LF/CR on the final dot, as qpsmtpd did before - 1.02. __NOTE:__ this accepts `.` as the end of the data, which - allows SMTP smuggling. Prefer `tolerant`. + + In both modes only `.` ends the data, so SMTP smuggling via + `.`, `.` or `.` is always rejected. An + unknown value logs a warning and falls back to `strict`. - size\_threshold diff --git a/lib/Qpsmtpd/SMTP.pm b/lib/Qpsmtpd/SMTP.pm index f8edbb7b..cd6a7047 100644 --- a/lib/Qpsmtpd/SMTP.pm +++ b/lib/Qpsmtpd/SMTP.pm @@ -678,14 +678,15 @@ sub command_line_too_long { # How a bare LF/CR in DATA is handled, from the 'barelf' config: # strict - reject any line not ending in CRLF (default) # tolerant - accept bare LF lines, but only . ends the data -# lazy - only reject a bare LF/CR on the final dot (pre 1.02 behaviour) +# There is deliberately no mode that accepts another end of data sequence, +# as that allows SMTP smuggling. sub barelf_mode { my $self = shift; my ($mode) = $self->config('barelf'); return 'strict' if !defined $mode; $mode = lc $mode; $mode =~ s/^\s+|\s+$//g; - return $mode if $mode =~ /^(?:strict|tolerant|lazy)$/; + return $mode if $mode =~ /^(?:strict|tolerant)$/; $self->log(LOGWARN, "unknown barelf mode '$mode', using strict"); return 'strict'; } @@ -758,12 +759,14 @@ sub data_respond { # A bare LF/CR is a permanent defect of the message, a retry would # fail the same way, so reject with a 5xx (RFC 5321 2.3.8). The # 'barelf' config relaxes this, see docs/config.md. + # Only . ends the data in any mode: a bare LF/CR on the + # final dot, or a final dot right after a bare LF (.), is + # always rejected to prevent SMTP smuggling. my $bare = $_ !~ /\r\n$/; - my $bare_dot = $_ eq ".\n" || $_ eq ".\r"; my $reject - = $barelf eq 'lazy' ? $bare_dot - : $barelf eq 'tolerant' ? $bare_dot || ($prev_bare && $_ eq ".\r\n") - : $bare; + = $barelf eq 'tolerant' + ? $_ eq ".\n" || $_ eq ".\r" || ($prev_bare && $_ eq ".\r\n") + : $bare; if ($reject) { $self->respond(554, 'Bare LF in message data not permitted, see RFC 5321 2.3.8 (#5.6.0)'); diff --git a/t/qpsmtpd-smtp.t b/t/qpsmtpd-smtp.t index 93659b6c..10a73b50 100644 --- a/t/qpsmtpd-smtp.t +++ b/t/qpsmtpd-smtp.t @@ -402,14 +402,6 @@ sub __data_respond_barelf { is( $code, 554, 'tolerant: bare LF terminator is rejected' ); ($code) = $with_mode->('tolerant', @smuggle); is( $code, 554, 'tolerant: . is rejected (SMTP smuggling)' ); - - ($code, $disc) = $with_mode->('lazy', @body); - isnt( $code, 554, 'lazy: bare LF in body is accepted' ); - isnt( $disc, 1, 'lazy: bare LF in body does not disconnect' ); - ($code) = $with_mode->('lazy', @bare_dot); - is( $code, 554, 'lazy: bare LF terminator is rejected' ); - ($code) = $with_mode->('lazy', @smuggle); - isnt( $code, 554, 'lazy: . ends the data, as before 1.02' ); } sub __clean_authentication_results {