]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Test] Cover file and shm input hardening
authorVsevolod Stakhov <vsevolod@rspamd.com>
Thu, 30 Jul 2026 18:07:04 +0000 (19:07 +0100)
committerVsevolod Stakhov <vsevolod@rspamd.com>
Thu, 30 Jul 2026 18:11:27 +0000 (19:11 +0100)
Add a C++ unit suite for the input layer: page-aligned window mapping
at awkward offsets, a small window of a large backing object,
zero-length windows mapping nothing, max_size enforcement, the
combined offset plus length overflow check, path validation (empty,
control characters, embedded NUL, url-encoded NUL, overlong), and
snapshot stability across a concurrent truncate and grow. Also pin the
semantics of the capability helpers, including that Filename is not a
privileged control. Cleanup is RAII so a failing assertion cannot leak
an object.

Add four functional suites covering the transport-derived behaviour
that cannot be reached from a unit test: v2 File/Path/Shm* and v3
file/shm metadata refused over TCP, the proxy refusing File as header
and as query argument, a unix socket retaining the capability while
the option is false, compatibility when it is true, client Shm*
headers never overriding the proxy-generated values, inline forwarding
to an upstream that may not receive shared memory, max_message
enforcement, and pending connections counting toward the scanner,
controller and proxy admission limits with counters released on
disconnect.

Each new suite has a verified negative control: flipping only the
config value makes exactly the intended tests fail and no others.

Supporting harness changes in lib/rspamd.py: an AF_UNIX HTTPConnection
so a unix-bound worker can be exercised, helpers for benign temporary
files and shared memory objects, and helpers for holding connections
open in the accepted-but-body-pending state.

Tests use only benign temporary files and shared memory objects they
create and clean up themselves.

14 files changed:
test/functional/cases/140_proxy.robot
test/functional/cases/570_file_shm_deny.robot [new file with mode: 0644]
test/functional/cases/571_file_shm_allow.robot [new file with mode: 0644]
test/functional/cases/572_file_shm_proxy.robot [new file with mode: 0644]
test/functional/cases/573_admission_limits.robot [new file with mode: 0644]
test/functional/configs/admission_limits.conf [new file with mode: 0644]
test/functional/configs/file_shm_allow.conf [new file with mode: 0644]
test/functional/configs/file_shm_backend.conf [new file with mode: 0644]
test/functional/configs/file_shm_deny.conf [new file with mode: 0644]
test/functional/configs/file_shm_proxy.conf [new file with mode: 0644]
test/functional/lib/rspamd.py
test/functional/lua/file_shm_probe.lua [new file with mode: 0644]
test/rspamd_cxx_unit.cxx
test/rspamd_cxx_unit_task_input.hxx [new file with mode: 0644]

index 79b6b1fedc0869e346853e63e2bb12fd204cab0a..6c62491394efd19122d57f421d0f3b54b798fc5a 100644 (file)
@@ -25,6 +25,18 @@ RSPAMC Legacy Protocol
   ${result} =  Rspamc  ${RSPAMD_LOCAL_ADDR}  ${RSPAMD_PORT_PROXY}  ${MESSAGE}
   Should Contain  ${result}  RSPAMD/1.3 0 EX_OK
 
+CLIENT SHM HEADERS CANNOT OVERRIDE THE PROXY ONES
+  [Documentation]  Shm/Shm-Offset/Shm-Length are reserved for the proxy's own
+  ...  shared body on the upstream leg. This proxy does forward through shared
+  ...  memory (loopback upstream, privileged inputs enabled), so the client's
+  ...  triplet must be dropped and exactly the proxy's own one regenerated. Had
+  ...  the client's values survived, the upstream would have read the segment
+  ...  named below -- which does not exist -- and the scan would have failed.
+  Set Test Variable  ${RSPAMD_PORT_NORMAL}  ${RSPAMD_PORT_PROXY}
+  Scan File  ${MESSAGE}  From=shmsmuggle@example.net  Rcpt=shmsmuggle-rcpt@example.net
+  ...  Shm=/rspamd-proxy-140-never-opened  Shm-Offset=0  Shm-Length=16
+  Expect Symbol  SIMPLE_TEST
+
 CHECKV3 VIA PROXY
   [Documentation]  Send /checkv3 multipart request through proxy, verify result
   Set Test Variable  ${RSPAMD_PORT_NORMAL}  ${RSPAMD_PORT_PROXY}
diff --git a/test/functional/cases/570_file_shm_deny.robot b/test/functional/cases/570_file_shm_deny.robot
new file mode 100644 (file)
index 0000000..8449f58
--- /dev/null
@@ -0,0 +1,124 @@
+*** Settings ***
+Suite Setup     File Shm Deny Setup
+Suite Teardown  Rspamd Teardown
+Library         ${RSPAMD_TESTDIR}/lib/rspamd.py
+Resource        ${RSPAMD_TESTDIR}/lib/rspamd.robot
+Variables       ${RSPAMD_TESTDIR}/lib/vars.py
+
+*** Variables ***
+${CONFIG}          ${RSPAMD_TESTDIR}/configs/file_shm_deny.conf
+${MESSAGE}         ${RSPAMD_TESTDIR}/messages/spam_message.eml
+${RSPAMD_SCOPE}    Suite
+${RSPAMD_URL_TLD}  ${RSPAMD_TESTDIR}/../lua/unit/test_tld.dat
+${DENIED}          file and shm message sources are not allowed
+# A name only. The refusal happens on header presence alone, before the value
+# is resolved, so nothing has to exist behind it and nothing has to be removed.
+${SHM_NAME}        /rspamd-file-shm-deny-never-opened
+
+*** Test Cases ***
+Inline body still scans over TCP
+  [Documentation]  Baseline: only the by-reference message sources are gated,
+  ...              an ordinary inline scan is untouched.
+  Scan File  ${MESSAGE}  From=inline@example.net  Rcpt=inline-rcpt@example.net
+  Expect Symbol  SIMPLE_TEST
+
+V2 File header is refused over TCP
+  ${body} =  Scan File Expect Error  /dev/null  400  File=${BENIGN_FILE}
+  ...  From=v2file@example.net  Rcpt=v2file-rcpt@example.net
+  Should Contain  ${body}  ${DENIED}
+
+V2 Path header is refused over TCP
+  [Documentation]  Path is an alias of File and has to be gated with it.
+  ${body} =  Scan File Expect Error  /dev/null  400  Path=${BENIGN_FILE}
+  ...  From=v2path@example.net  Rcpt=v2path-rcpt@example.net
+  Should Contain  ${body}  ${DENIED}
+
+V2 Shm header is refused over TCP
+  ${body} =  Scan File Expect Error  /dev/null  400  Shm=${SHM_NAME}
+  ...  From=v2shm@example.net  Rcpt=v2shm-rcpt@example.net
+  Should Contain  ${body}  ${DENIED}
+
+V2 Shm-Offset header is refused over TCP
+  ${body} =  Scan File Expect Error  /dev/null  400  Shm-Offset=0
+  ...  From=v2shmoff@example.net  Rcpt=v2shmoff-rcpt@example.net
+  Should Contain  ${body}  ${DENIED}
+
+V2 Shm-Length header is refused over TCP
+  ${body} =  Scan File Expect Error  /dev/null  400  Shm-Length=16
+  ...  From=v2shmlen@example.net  Rcpt=v2shmlen-rcpt@example.net
+  Should Contain  ${body}  ${DENIED}
+
+V2 Shm triplet is refused over TCP
+  ${body} =  Scan File Expect Error  /dev/null  400
+  ...  Shm=${SHM_NAME}  Shm-Offset=0  Shm-Length=16
+  ...  From=v2shmall@example.net  Rcpt=v2shmall-rcpt@example.net
+  Should Contain  ${body}  ${DENIED}
+
+V2 File header is refused by the controller over TCP
+  [Documentation]  The controller scan endpoint is gated by its own worker
+  ...              option, and a secure_ip match does not unlock it.
+  ${body} =  Scan File Expect Error  /dev/null  400  port=${RSPAMD_PORT_CONTROLLER}
+  ...  File=${BENIGN_FILE}  From=ctrlfile@example.net  Rcpt=ctrlfile-rcpt@example.net
+  Should Contain  ${body}  ${DENIED}
+
+V2 Shm header is refused by the controller over TCP
+  ${body} =  Scan File Expect Error  /dev/null  400  port=${RSPAMD_PORT_CONTROLLER}
+  ...  Shm=${SHM_NAME}  From=ctrlshm@example.net  Rcpt=ctrlshm-rcpt@example.net
+  Should Contain  ${body}  ${DENIED}
+
+V3 file metadata is refused over TCP
+  ${meta} =  Create Dictionary  file=${BENIGN_FILE}
+  Scan File V3 Expect Error  ${MESSAGE}  400  metadata=${meta}
+  ...  From=v3file@example.net  Rcpt=v3file-rcpt@example.net
+
+V3 shm metadata is refused over TCP
+  ${meta} =  Create Dictionary  shm=${SHM_NAME}
+  Scan File V3 Expect Error  ${MESSAGE}  400  metadata=${meta}
+  ...  From=v3shm@example.net  Rcpt=v3shm-rcpt@example.net
+
+Encrypted connection does not unlock the File message source
+  [Documentation]  Encryption establishes that the request was not tampered
+  ...              with in transit, not that the client shares a filesystem
+  ...              with the daemon, so it must not widen what may be named.
+  ${result} =  Run Rspamc  -p  -h  ${RSPAMD_LOCAL_ADDR}:${RSPAMD_PORT_NORMAL}
+  ...  --key  ${RSPAMD_KEY_PUB1}  --header=File=${BENIGN_FILE}
+  ...  --header=From=encfile@example.net  --header=Rcpt=encfile-rcpt@example.net
+  ...  /dev/null
+  Should Contain  ${result.stdout}${result.stderr}  ${DENIED}
+
+Unix socket keeps the File message source
+  [Documentation]  The capability follows the transport: the very same request
+  ...              that is refused above succeeds on a socket whose access is
+  ...              controlled by filesystem permissions (mode=0600). The URL
+  ...              only exists inside the referenced file, so finding it proves
+  ...              the file was really read and not merely named.
+  Scan File Over Unix Socket  ${SCAN_SOCKET}  /dev/null  File=${BENIGN_FILE}
+  ...  From=unixfile@example.net  Rcpt=unixfile-rcpt@example.net
+  Expect Symbol  SIMPLE_TEST
+  Expect URL  file-by-reference.example.net
+
+Unix socket keeps the File message source with an encoded name
+  Scan File Over Unix Socket  ${SCAN_SOCKET}  /dev/null  File=${ENCODED_FILE}
+  ...  From=unixenc@example.net  Rcpt=unixenc-rcpt@example.net
+  Expect Symbol  SIMPLE_TEST
+  Expect URL  file-by-reference.example.net
+
+*** Keywords ***
+File Shm Deny Setup
+  Rspamd Setup
+  # Rspamd Startup Check only pings a TCP port, so the unix listener of the
+  # very same worker needs its own barrier before the first request.
+  Set Suite Variable  ${SCAN_SOCKET}  ${RSPAMD_TMPDIR}/scan.sock
+  Wait Until Keyword Succeeds  30x  0.2s  Unix Socket Connect  ${SCAN_SOCKET}
+  Make Benign File
+
+Make Benign File
+  [Documentation]  An ordinary message on disk carrying a URL that nothing else
+  ...  in this suite contains. It lives in the suite tmpdir, which is
+  ...  world-readable and is removed by Rspamd Teardown on success and on
+  ...  failure alike.
+  ${path} =  Write Readable File  ${RSPAMD_TMPDIR}/by-reference.eml
+  ...  From: <byref@example.net>\nTo: <byref-rcpt@example.net>\nSubject: scanned by reference\n\nSee http://file-by-reference.example.net/ for details.\n
+  Set Suite Variable  ${BENIGN_FILE}  ${path}
+  ${encoded} =  Encode Filename  ${path}
+  Set Suite Variable  ${ENCODED_FILE}  ${encoded}
diff --git a/test/functional/cases/571_file_shm_allow.robot b/test/functional/cases/571_file_shm_allow.robot
new file mode 100644 (file)
index 0000000..2a85382
--- /dev/null
@@ -0,0 +1,89 @@
+*** Settings ***
+Suite Setup     File Shm Allow Setup
+Suite Teardown  File Shm Allow Teardown
+Library         ${RSPAMD_TESTDIR}/lib/rspamd.py
+Resource        ${RSPAMD_TESTDIR}/lib/rspamd.robot
+Variables       ${RSPAMD_TESTDIR}/lib/vars.py
+
+*** Variables ***
+${CONFIG}          ${RSPAMD_TESTDIR}/configs/file_shm_allow.conf
+${MESSAGE}         ${RSPAMD_TESTDIR}/messages/spam_message.eml
+${RSPAMD_SCOPE}    Suite
+${RSPAMD_URL_TLD}  ${RSPAMD_TESTDIR}/../lua/unit/test_tld.dat
+# Matches options.max_message in configs/file_shm_allow.conf
+${MAX_MESSAGE}     ${65536}
+
+*** Test Cases ***
+File message source still works over TCP
+  [Documentation]  Compatibility: with allow_file_and_shm_inputs = true the
+  ...              historical by-reference behaviour is unchanged. The URL
+  ...              only exists inside the referenced file.
+  Scan File By Reference  ${BENIGN_FILE}
+  ...  From=allowfile@example.net  Rcpt=allowfile-rcpt@example.net
+  Expect Symbol  SIMPLE_TEST
+  Expect URL  file-by-reference.example.net
+
+File message source still works over TCP with an encoded name
+  ${encoded} =  Encode Filename  ${BENIGN_FILE}
+  Scan File By Reference  ${encoded}
+  ...  From=allowenc@example.net  Rcpt=allowenc-rcpt@example.net
+  Expect Symbol  SIMPLE_TEST
+  Expect URL  file-by-reference.example.net
+
+Path message source still works over TCP
+  Scan File  /dev/null  Path=${BENIGN_FILE}
+  ...  From=allowpath@example.net  Rcpt=allowpath-rcpt@example.net
+  Expect Symbol  SIMPLE_TEST
+  Expect URL  file-by-reference.example.net
+
+Shm message source still works over TCP
+  ${name}  ${path} =  Create Shm Payload  content=${SHM_MESSAGE}
+  TRY
+    Scan File  /dev/null  Shm=${name}
+    ...  From=allowshm@example.net  Rcpt=allowshm-rcpt@example.net
+    Expect Symbol  SIMPLE_TEST
+    Expect URL  shm-by-reference.example.net
+  FINALLY
+    Remove Shm Payload  ${path}
+  END
+
+V3 file metadata still works over TCP
+  ${meta} =  Create Dictionary  file=${BENIGN_FILE}
+  Scan File V3  ${MESSAGE}  metadata=${meta}
+  ...  From=allowv3file@example.net  Rcpt=allowv3file-rcpt@example.net
+  Expect Symbol  SIMPLE_TEST
+  Expect URL  file-by-reference.example.net
+
+File larger than max_message is rejected
+  [Documentation]  A by-reference file must obey the very same size limit as
+  ...              an inline body. The legacy 5xx mapping applies here: this is
+  ...              a protocol error, not the new client-error gate.
+  ${body} =  Scan File Expect Error  /dev/null  503  File=${OVERSIZED_FILE}
+  ...  From=allowbigfile@example.net  Rcpt=allowbigfile-rcpt@example.net
+  Should Contain  ${body}  Too large file
+
+Shm payload larger than max_message is rejected
+  ${name}  ${path} =  Create Shm Payload  size=${OVERSIZED}
+  TRY
+    ${body} =  Scan File Expect Error  /dev/null  503  Shm=${name}
+    ...  From=allowbigshm@example.net  Rcpt=allowbigshm-rcpt@example.net
+    Should Contain  ${body}  too large
+  FINALLY
+    Remove Shm Payload  ${path}
+  END
+
+*** Keywords ***
+File Shm Allow Setup
+  Rspamd Setup
+  ${oversized} =  Evaluate  ${MAX_MESSAGE} + 4096
+  Set Suite Variable  ${OVERSIZED}  ${oversized}
+  ${path} =  Write Readable File  ${RSPAMD_TMPDIR}/by-reference.eml
+  ...  From: <byref@example.net>\nTo: <byref-rcpt@example.net>\nSubject: scanned by reference\n\nSee http://file-by-reference.example.net/ for details.\n
+  Set Suite Variable  ${BENIGN_FILE}  ${path}
+  ${big} =  Write Filler File  ${RSPAMD_TMPDIR}/oversized.eml  ${oversized}
+  Set Suite Variable  ${OVERSIZED_FILE}  ${big}
+  Set Suite Variable  ${SHM_MESSAGE}
+  ...  From: <shmref@example.net>\nTo: <shmref-rcpt@example.net>\nSubject: scanned from shared memory\n\nSee http://shm-by-reference.example.net/ for details.\n
+
+File Shm Allow Teardown
+  Rspamd Teardown
diff --git a/test/functional/cases/572_file_shm_proxy.robot b/test/functional/cases/572_file_shm_proxy.robot
new file mode 100644 (file)
index 0000000..f38ee2f
--- /dev/null
@@ -0,0 +1,141 @@
+*** Settings ***
+Suite Setup     File Shm Proxy Setup
+Suite Teardown  File Shm Proxy Teardown
+Library         ${RSPAMD_TESTDIR}/lib/rspamd.py
+Resource        ${RSPAMD_TESTDIR}/lib/rspamd.robot
+Variables       ${RSPAMD_TESTDIR}/lib/vars.py
+
+*** Variables ***
+${MESSAGE}         ${RSPAMD_TESTDIR}/messages/spam_message.eml
+${RSPAMD_SCOPE}    Suite
+${RSPAMD_URL_TLD}  ${RSPAMD_TESTDIR}/../lua/unit/test_tld.dat
+${DENIED}          File and shm message sources are not allowed
+# A name only: the proxy refuses or strips these controls before resolving
+# them, so nothing has to exist behind it.
+${SHM_NAME}        /rspamd-proxy-shm-never-opened
+
+*** Test Cases ***
+TCP upstream is scanned through an inline body
+  [Documentation]  Shared memory forwarding is not permitted for this upstream,
+  ...              so the proxy has to send the message inline. The scan must
+  ...              still succeed with the upstream's own verdict, and the
+  ...              upstream must see no privileged message source header at all.
+  Set Test Variable  ${RSPAMD_PORT_NORMAL}  ${RSPAMD_PORT_PROXY}
+  Scan File  ${MESSAGE}  From=inline@example.net  Rcpt=inline-rcpt@example.net
+  Expect Symbol  SIMPLE_TEST
+  Expect Symbol With Exact Options  FILE_SHM_PROBE  none
+
+Proxy refuses the File query argument
+  [Documentation]  A query argument becomes a request header at the upstream,
+  ...              so the URL has to be sanitised as thoroughly as the headers.
+  ${data} =  Get Binary File  ${MESSAGE}
+  ${headers} =  Create Dictionary  Queue-Id=${TEST NAME}
+  ...  From=qfile@example.net  Rcpt=qfile-rcpt@example.net
+  @{result} =  HTTP Status And Reason  POST  ${RSPAMD_LOCAL_ADDR}  ${RSPAMD_PORT_PROXY}
+  ...  /checkv2?File=${BENIGN_FILE}  ${data}  ${headers}
+  Should Be Equal As Integers  ${result}[0]  400
+  Should Contain  ${result}[1]  ${DENIED}
+
+Proxy refuses the Path query argument
+  ${data} =  Get Binary File  ${MESSAGE}
+  ${headers} =  Create Dictionary  Queue-Id=${TEST NAME}
+  ...  From=qpath@example.net  Rcpt=qpath-rcpt@example.net
+  @{result} =  HTTP Status And Reason  POST  ${RSPAMD_LOCAL_ADDR}  ${RSPAMD_PORT_PROXY}
+  ...  /checkv2?Path=${BENIGN_FILE}  ${data}  ${headers}
+  Should Be Equal As Integers  ${result}[0]  400
+  Should Contain  ${result}[1]  ${DENIED}
+
+Proxy refuses the File header
+  ${data} =  Get Binary File  ${MESSAGE}
+  ${headers} =  Create Dictionary  Queue-Id=${TEST NAME}  File=${BENIGN_FILE}
+  ...  From=hfile@example.net  Rcpt=hfile-rcpt@example.net
+  @{result} =  HTTP Status And Reason  POST  ${RSPAMD_LOCAL_ADDR}  ${RSPAMD_PORT_PROXY}
+  ...  /checkv2  ${data}  ${headers}
+  Should Be Equal As Integers  ${result}[0]  400
+  Should Contain  ${result}[1]  ${DENIED}
+
+Proxy refuses the Path header
+  ${data} =  Get Binary File  ${MESSAGE}
+  ${headers} =  Create Dictionary  Queue-Id=${TEST NAME}  Path=${BENIGN_FILE}
+  ...  From=hpath@example.net  Rcpt=hpath-rcpt@example.net
+  @{result} =  HTTP Status And Reason  POST  ${RSPAMD_LOCAL_ADDR}  ${RSPAMD_PORT_PROXY}
+  ...  /checkv2  ${data}  ${headers}
+  Should Be Equal As Integers  ${result}[0]  400
+  Should Contain  ${result}[1]  ${DENIED}
+
+Client Shm headers never reach the upstream
+  [Documentation]  Shm/Shm-Offset/Shm-Length are hop-by-hop and are stripped at
+  ...              ingress, so the upstream -- which does honour privileged
+  ...              inputs -- must not observe a single one of them. If any had
+  ...              survived, the upstream would have tried to open the named
+  ...              segment and the scan would have failed instead.
+  Set Test Variable  ${RSPAMD_PORT_NORMAL}  ${RSPAMD_PORT_PROXY}
+  Scan File  ${MESSAGE}  Shm=${SHM_NAME}  Shm-Offset=0  Shm-Length=16
+  ...  From=smuggle@example.net  Rcpt=smuggle-rcpt@example.net
+  Expect Symbol  SIMPLE_TEST
+  Expect Symbol With Exact Options  FILE_SHM_PROBE  none
+
+Client Shm query arguments never reach the upstream
+  ${data} =  Get Binary File  ${MESSAGE}
+  ${headers} =  Create Dictionary  Queue-Id=${TEST NAME}
+  ...  From=qsmuggle@example.net  Rcpt=qsmuggle-rcpt@example.net
+  @{result} =  HTTP Status And Reason  POST  ${RSPAMD_LOCAL_ADDR}  ${RSPAMD_PORT_PROXY}
+  ...  /checkv2?Shm=${SHM_NAME}&Shm-Offset=0&Shm-Length=16  ${data}  ${headers}
+  Should Be Equal As Integers  ${result}[0]  200
+  ${json} =  Evaluate  __import__('json').loads($result[2])
+  Set Test Variable  ${SCAN_RESULT}  ${json}
+  Expect Symbol  SIMPLE_TEST
+  Expect Symbol With Exact Options  FILE_SHM_PROBE  none
+
+Client Shm headers cannot override a permissive proxy's own triplet
+  [Documentation]  The other proxy worker does forward through shared memory,
+  ...              so a triplet really is generated on the upstream leg. It
+  ...              must be the proxy's own one: the client's values are
+  ...              reserved-header noise and are dropped before it is written,
+  ...              otherwise the upstream would read an object of the client's
+  ...              choosing.
+  Set Test Variable  ${RSPAMD_PORT_NORMAL}  ${RSPAMD_PORT_NORMAL_SLAVE}
+  Scan File  ${MESSAGE}  Shm=${SHM_NAME}  Shm-Offset=4096  Shm-Length=16
+  ...  From=override@example.net  Rcpt=override-rcpt@example.net
+  Expect Symbol  SIMPLE_TEST
+  ${options} =  Convert To List  ${SCAN_RESULT}[symbols][FILE_SHM_PROBE][options]
+  ${seen} =  Catenate  SEPARATOR=;  @{options}
+  Should Contain  ${seen}  Shm=  msg=the proxy did not generate a shared body at all
+  Should Not Contain  ${seen}  ${SHM_NAME}
+  Should Not Contain  ${seen}  Shm-Offset=4096
+  Should Not Contain  ${seen}  Shm-Length=16
+
+*** Keywords ***
+File Shm Proxy Setup
+  # Run the upstream scanner & copy variables. It is deliberately permissive,
+  # so a smuggled File/Shm control would really be honoured there.
+  Set Suite Variable  ${CONFIG}  ${RSPAMD_TESTDIR}/configs/file_shm_backend.conf
+  Rspamd Setup
+  Set Suite Variable  ${SLAVE_PROCESS}  ${RSPAMD_PROCESS}
+  Set Suite Variable  ${SLAVE_TMPDIR}  ${RSPAMD_TMPDIR}
+  ${path} =  Write Readable File  ${RSPAMD_TMPDIR}/by-reference.eml
+  ...  From: <byref@example.net>\nTo: <byref-rcpt@example.net>\nSubject: scanned by reference\n\nSee http://file-by-reference.example.net/ for details.\n
+  Set Suite Variable  ${BENIGN_FILE}  ${path}
+
+  # Run the proxy & copy variables
+  Set Suite Variable  ${CONFIG}  ${RSPAMD_TESTDIR}/configs/file_shm_proxy.conf
+  Rspamd Setup  check_port=${RSPAMD_PORT_PROXY}
+  Set Suite Variable  ${PROXY_PROCESS}  ${RSPAMD_PROCESS}
+  Set Suite Variable  ${PROXY_TMPDIR}  ${RSPAMD_TMPDIR}
+  # Rspamd Startup Check only pings the first proxy port
+  Wait Until Keyword Succeeds  30x  0.2s
+  ...  TCP Connect  ${RSPAMD_LOCAL_ADDR}  ${RSPAMD_PORT_NORMAL_SLAVE}
+
+File Shm Proxy Teardown
+  # Restore variables & run normal teardown
+  Set Suite Variable  ${RSPAMD_PROCESS}  ${PROXY_PROCESS}
+  Set Suite Variable  ${RSPAMD_TMPDIR}  ${PROXY_TMPDIR}
+  Rspamd Teardown
+  # The permissive proxy listens on a port that Wait For Rspamd Ports Released
+  # does not know about, and the next suite on this pabot worker rebinds it.
+  Run Keyword And Warn On Failure  Wait Until Keyword Succeeds  30x  0.2s
+  ...  Port Is Free  ${RSPAMD_LOCAL_ADDR}  ${RSPAMD_PORT_NORMAL_SLAVE}
+  # Do it again for the upstream scanner
+  Set Suite Variable  ${RSPAMD_PROCESS}  ${SLAVE_PROCESS}
+  Set Suite Variable  ${RSPAMD_TMPDIR}  ${SLAVE_TMPDIR}
+  Rspamd Teardown
diff --git a/test/functional/cases/573_admission_limits.robot b/test/functional/cases/573_admission_limits.robot
new file mode 100644 (file)
index 0000000..7a28f17
--- /dev/null
@@ -0,0 +1,79 @@
+*** Settings ***
+Suite Setup     Rspamd Setup
+Suite Teardown  Rspamd Teardown
+Test Teardown   Close Pending Connections
+Library         ${RSPAMD_TESTDIR}/lib/rspamd.py
+Resource        ${RSPAMD_TESTDIR}/lib/rspamd.robot
+Variables       ${RSPAMD_TESTDIR}/lib/vars.py
+
+*** Variables ***
+${CONFIG}          ${RSPAMD_TESTDIR}/configs/admission_limits.conf
+${MESSAGE}         ${RSPAMD_TESTDIR}/messages/spam_message.eml
+${RSPAMD_SCOPE}    Suite
+${RSPAMD_URL_TLD}  ${RSPAMD_TESTDIR}/../lua/unit/test_tld.dat
+# Matches max_tasks / max_connections in configs/admission_limits.conf
+${LIMIT}           ${3}
+${LIMIT_LESS_ONE}  ${2}
+
+*** Test Cases ***
+Scanner counts body-pending connections
+  [Documentation]  max_tasks used to count completed requests only, so a client
+  ...              that connected and then stalled did not occupy a slot. It
+  ...              does now: one slot short of the limit still admits a scan,
+  ...              reaching the limit refuses the next connection.
+  Assert Admitted  ${RSPAMD_PORT_NORMAL}
+  Open Pending Connections  ${RSPAMD_LOCAL_ADDR}  ${RSPAMD_PORT_NORMAL}  ${LIMIT_LESS_ONE}
+  Assert Admitted  ${RSPAMD_PORT_NORMAL}
+  Open Pending Connections  ${RSPAMD_LOCAL_ADDR}  ${RSPAMD_PORT_NORMAL}  1
+  Assert Refused  ${RSPAMD_PORT_NORMAL}
+
+Scanner releases the slots of disconnected clients
+  [Documentation]  The counter must be released on the teardown path as well,
+  ...              otherwise a burst of stalled clients would wedge the worker
+  ...              for good.
+  Open Pending Connections  ${RSPAMD_LOCAL_ADDR}  ${RSPAMD_PORT_NORMAL}  ${LIMIT}
+  Assert Refused  ${RSPAMD_PORT_NORMAL}
+  Close Pending Connections
+  Wait Until Keyword Succeeds  20x  0.25s  Assert Admitted  ${RSPAMD_PORT_NORMAL}
+  Scan File  ${MESSAGE}  From=released@example.net  Rcpt=released-rcpt@example.net
+  Expect Symbol  SIMPLE_TEST
+
+Controller counts pending connections
+  Assert Admitted  ${RSPAMD_PORT_CONTROLLER}
+  Open Pending Connections  ${RSPAMD_LOCAL_ADDR}  ${RSPAMD_PORT_CONTROLLER}  ${LIMIT_LESS_ONE}
+  Assert Admitted  ${RSPAMD_PORT_CONTROLLER}
+  Open Pending Connections  ${RSPAMD_LOCAL_ADDR}  ${RSPAMD_PORT_CONTROLLER}  1
+  Assert Refused  ${RSPAMD_PORT_CONTROLLER}
+
+Controller releases the slots of disconnected clients
+  Open Pending Connections  ${RSPAMD_LOCAL_ADDR}  ${RSPAMD_PORT_CONTROLLER}  ${LIMIT}
+  Assert Refused  ${RSPAMD_PORT_CONTROLLER}
+  Close Pending Connections
+  Wait Until Keyword Succeeds  20x  0.25s  Assert Admitted  ${RSPAMD_PORT_CONTROLLER}
+
+Proxy counts pending connections
+  Assert Admitted  ${RSPAMD_PORT_PROXY}
+  Open Pending Connections  ${RSPAMD_LOCAL_ADDR}  ${RSPAMD_PORT_PROXY}  ${LIMIT_LESS_ONE}
+  Assert Admitted  ${RSPAMD_PORT_PROXY}
+  Open Pending Connections  ${RSPAMD_LOCAL_ADDR}  ${RSPAMD_PORT_PROXY}  1
+  Assert Refused  ${RSPAMD_PORT_PROXY}
+
+Proxy releases the slots of disconnected clients
+  Open Pending Connections  ${RSPAMD_LOCAL_ADDR}  ${RSPAMD_PORT_PROXY}  ${LIMIT}
+  Assert Refused  ${RSPAMD_PORT_PROXY}
+  Close Pending Connections
+  Wait Until Keyword Succeeds  20x  0.25s  Assert Admitted  ${RSPAMD_PORT_PROXY}
+  Set Test Variable  ${RSPAMD_PORT_NORMAL}  ${RSPAMD_PORT_PROXY}
+  Scan File  ${MESSAGE}  From=proxyreleased@example.net  Rcpt=proxyreleased-rcpt@example.net
+  Expect Symbol  SIMPLE_TEST
+
+*** Keywords ***
+Assert Admitted
+  [Arguments]  ${port}
+  ${ok} =  Connection Admitted  ${RSPAMD_LOCAL_ADDR}  ${port}
+  Should Be True  ${ok}  msg=connection to ${port} was refused but the limit is not reached
+
+Assert Refused
+  [Arguments]  ${port}
+  ${ok} =  Connection Admitted  ${RSPAMD_LOCAL_ADDR}  ${port}
+  Should Not Be True  ${ok}  msg=connection to ${port} was admitted past the configured limit
diff --git a/test/functional/configs/admission_limits.conf b/test/functional/configs/admission_limits.conf
new file mode 100644 (file)
index 0000000..8ec0017
--- /dev/null
@@ -0,0 +1,52 @@
+options = {
+       filters = ["spf", "dkim", "regexp"]
+       url_tld = "{= env.URL_TLD =}"
+       pidfile = "{= env.TMPDIR =}/rspamd.pid"
+       lua_path = "{= env.INSTALLROOT =}/share/rspamd/lib/?.lua"
+       dns {
+               retransmits = 2;
+       }
+}
+logging = {
+       type = "file",
+       level = "debug"
+       filename = "{= env.TMPDIR =}/rspamd.log"
+}
+metric = {
+       name = "default",
+       actions = {
+               reject = 100500,
+       }
+       unknown_weight = 1
+}
+
+# Every limit below is deliberately tiny so that the tests can reach it with a
+# handful of sockets. count = 1 everywhere: the counters are per worker
+# process, so more than one process would scatter the connections.
+worker {
+       type = normal
+       bind_socket = "{= env.LOCAL_ADDR =}:{= env.PORT_NORMAL =}"
+       count = 1
+       task_timeout = 10s;
+       max_tasks = 3;
+}
+worker {
+       type = controller
+       bind_socket = "{= env.LOCAL_ADDR =}:{= env.PORT_CONTROLLER =}"
+       count = 1
+       secure_ip = ["127.0.0.1", "::1"];
+       stats_path = "{= env.TMPDIR =}/stats.ucl"
+       max_connections = 3;
+}
+worker "rspamd_proxy" {
+       bind_socket = "{= env.LOCAL_ADDR =}:{= env.PORT_PROXY =}";
+       upstream {
+               name = "{= env.LOCAL_ADDR =}";
+               default = yes;
+               hosts = "{= env.LOCAL_ADDR =}:{= env.PORT_NORMAL =}";
+       }
+       count = 1;
+       max_connections = 3;
+}
+lua = "{= env.TESTDIR =}/lua/test_coverage.lua";
+lua = "{= env.TESTDIR =}/lua/simple.lua";
diff --git a/test/functional/configs/file_shm_allow.conf b/test/functional/configs/file_shm_allow.conf
new file mode 100644 (file)
index 0000000..518e678
--- /dev/null
@@ -0,0 +1,44 @@
+options = {
+       filters = ["spf", "dkim", "regexp"]
+       url_tld = "{= env.URL_TLD =}"
+       pidfile = "{= env.TMPDIR =}/rspamd.pid"
+       # Small on purpose: file and shm inputs must obey the very same size limit
+       # as an inline body, and the limit has to be small enough to be exceeded by
+       # a test payload without writing a huge file.
+       max_message = 65536;
+       dns {
+               retransmits = 2;
+       }
+}
+logging = {
+       type = "file",
+       level = "debug"
+       filename = "{= env.TMPDIR =}/rspamd.log"
+}
+metric = {
+       name = "default",
+       actions = {
+               reject = 100500,
+       }
+       unknown_weight = 1
+}
+
+# The compatibility side of the option: privileged inputs stay available over
+# TCP when the operator opts in explicitly.
+worker {
+       type = normal
+       bind_socket = "{= env.LOCAL_ADDR =}:{= env.PORT_NORMAL =}"
+       count = 1
+       task_timeout = 10s;
+       allow_file_and_shm_inputs = true;
+}
+worker {
+       type = controller
+       bind_socket = "{= env.LOCAL_ADDR =}:{= env.PORT_CONTROLLER =}"
+       count = 1
+       secure_ip = ["127.0.0.1", "::1"];
+       stats_path = "{= env.TMPDIR =}/stats.ucl"
+       allow_file_and_shm_inputs = true;
+}
+lua = "{= env.TESTDIR =}/lua/test_coverage.lua";
+lua = "{= env.TESTDIR =}/lua/simple.lua";
diff --git a/test/functional/configs/file_shm_backend.conf b/test/functional/configs/file_shm_backend.conf
new file mode 100644 (file)
index 0000000..d1bad57
--- /dev/null
@@ -0,0 +1,40 @@
+options = {
+       filters = ["spf", "dkim", "regexp"]
+       url_tld = "{= env.URL_TLD =}"
+       pidfile = "{= env.TMPDIR =}/rspamd.pid"
+       dns {
+               retransmits = 2;
+       }
+}
+logging = {
+       type = "file",
+       level = "debug"
+       filename = "{= env.TMPDIR =}/rspamd.log"
+}
+metric = {
+       name = "default",
+       actions = {
+               reject = 100500,
+       }
+       unknown_weight = 1
+}
+
+# Deliberately permissive: this is the upstream of the proxy suite, so a
+# File/Shm control smuggled through the proxy would actually be honoured here.
+# The proxy must make sure none ever arrives.
+worker {
+       type = normal
+       bind_socket = "{= env.LOCAL_ADDR =}:{= env.PORT_NORMAL =}"
+       count = 1
+       task_timeout = 10s;
+       allow_file_and_shm_inputs = true;
+}
+worker {
+       type = controller
+       bind_socket = "{= env.LOCAL_ADDR =}:{= env.PORT_CONTROLLER =}"
+       count = 1
+       secure_ip = ["127.0.0.1", "::1"];
+       stats_path = "{= env.TMPDIR =}/stats.ucl"
+}
+lua = "{= env.TESTDIR =}/lua/test_coverage.lua";
+lua = "{= env.TESTDIR =}/lua/file_shm_probe.lua";
diff --git a/test/functional/configs/file_shm_deny.conf b/test/functional/configs/file_shm_deny.conf
new file mode 100644 (file)
index 0000000..1889f8d
--- /dev/null
@@ -0,0 +1,47 @@
+options = {
+       filters = ["spf", "dkim", "regexp"]
+       url_tld = "{= env.URL_TLD =}"
+       pidfile = "{= env.TMPDIR =}/rspamd.pid"
+       dns {
+               retransmits = 2;
+       }
+}
+logging = {
+       type = "file",
+       level = "debug"
+       filename = "{= env.TMPDIR =}/rspamd.log"
+}
+metric = {
+       name = "default",
+       actions = {
+               reject = 100500,
+       }
+       unknown_weight = 1
+}
+
+# Privileged File/Path/Shm message sources are refused on the TCP listener and
+# kept on the unix socket, which is protected by its filesystem permissions.
+worker {
+       type = normal
+       bind_socket = "{= env.LOCAL_ADDR =}:{= env.PORT_NORMAL =}"
+       bind_socket = "{= env.TMPDIR =}/scan.sock mode=0600"
+       count = 1
+       task_timeout = 10s;
+       allow_file_and_shm_inputs = false;
+       # Encryption protects a request in transit; it must not widen what message
+       # sources the connection may name.
+       keypair {
+               pubkey = "{= env.KEY_PUB1 =}";
+               privkey = "{= env.KEY_PVT1 =}";
+       }
+}
+worker {
+       type = controller
+       bind_socket = "{= env.LOCAL_ADDR =}:{= env.PORT_CONTROLLER =}"
+       count = 1
+       secure_ip = ["127.0.0.1", "::1"];
+       stats_path = "{= env.TMPDIR =}/stats.ucl"
+       allow_file_and_shm_inputs = false;
+}
+lua = "{= env.TESTDIR =}/lua/test_coverage.lua";
+lua = "{= env.TESTDIR =}/lua/simple.lua";
diff --git a/test/functional/configs/file_shm_proxy.conf b/test/functional/configs/file_shm_proxy.conf
new file mode 100644 (file)
index 0000000..4b4cad0
--- /dev/null
@@ -0,0 +1,40 @@
+options = {
+       filters = ["spf", "dkim", "regexp"]
+       url_tld = "{= env.URL_TLD =}"
+       pidfile = "{= env.TMPDIR =}/rspamd.pid"
+       lua_path = "{= env.INSTALLROOT =}/share/rspamd/lib/?.lua"
+       dns {
+               retransmits = 2;
+       }
+}
+logging = {
+       type = "file",
+       level = "debug"
+       filename = "{= env.TMPDIR =}/rspamd.log"
+}
+worker "rspamd_proxy" {
+    bind_socket = "{= env.LOCAL_ADDR =}:{= env.PORT_PROXY =}";
+    upstream {
+        name = "{= env.LOCAL_ADDR =}";
+        default = yes;
+        hosts = "{= env.LOCAL_ADDR =}:{= env.PORT_NORMAL =}";
+    }
+    count = 1;
+    # The File query argument and the File/Path/Shm* headers are refused, and
+    # the TCP upstream gets a bounded inline body instead of a shared segment.
+    allow_file_and_shm_inputs = false;
+}
+# The permissive counterpart, so that the reserved Shm triplet can be observed
+# where the proxy really does forward through shared memory. Same upstream, so
+# the probe symbol reports whichever triplet actually arrived there.
+worker "rspamd_proxy" {
+    bind_socket = "{= env.LOCAL_ADDR =}:{= env.PORT_NORMAL_SLAVE =}";
+    upstream {
+        name = "{= env.LOCAL_ADDR =}";
+        default = yes;
+        hosts = "{= env.LOCAL_ADDR =}:{= env.PORT_NORMAL =}";
+    }
+    count = 1;
+    allow_file_and_shm_inputs = true;
+}
+lua = "{= env.TESTDIR =}/lua/test_coverage.lua";
index da7da1c1c7d164d81c3f9de2aa05c49677ebfe4f..0a5a0e88b71589ed8d5bd68bf35134c249e65461 100644 (file)
@@ -224,6 +224,23 @@ def HTTP_With_Headers(method, host, port, path, data=None, headers={}):
     return [s, t, h]
 
 
+def HTTP_Status_And_Reason(method, host, port, path, data=None, headers={}):
+    """HTTP request that returns [status, reason, body].
+
+    rspamd_proxy reports a refused request in the status line only -- that
+    path writes no body at all -- so the reason phrase is the only assertable
+    text.
+    """
+    c = http.client.HTTPConnection("%s:%s" % (host, port))
+    c.request(method, path, data, headers)
+    r = c.getresponse()
+    t = r.read()
+    s = r.status
+    reason = r.reason
+    c.close()
+    return [s, reason, t]
+
+
 def HTTPS(method, host, port, path, data=None, headers={}):
     ctx = ssl.create_default_context()
     ctx.check_hostname = False
@@ -338,6 +355,242 @@ def Scan_File(filename, **headers):
     return
 
 
+def Scan_File_Expect_Error(filename, expected_status, port=None, **headers):
+    """POST /checkv2 and require a specific HTTP status; return the body.
+
+    Scan_File asserts 200, so a request that must be refused needs its own
+    entry point. The body is returned so the caller can assert on the
+    protocol error text rather than on the status alone.
+
+    Example:
+    | ${body} = | Scan File Expect Error | /dev/null | 400 | File=/tmp/x |
+    """
+    addr = BuiltIn().get_variable_value("${RSPAMD_LOCAL_ADDR}")
+    if port is None:
+        port = BuiltIn().get_variable_value("${RSPAMD_PORT_NORMAL}")
+    headers["Queue-Id"] = BuiltIn().get_variable_value("${TEST_NAME}")
+    c = http.client.HTTPConnection("%s:%s" % (addr, port))
+    c.request("POST", "/checkv2", open(filename, "rb"), headers)
+    r = c.getresponse()
+    status = r.status
+    body = r.read().decode('utf-8', errors='replace')
+    c.close()
+    assert status == int(expected_status), \
+        "Expected HTTP %s but got %d: %s" % (expected_status, status, body)
+    return body
+
+
+class _UnixHTTPConnection(http.client.HTTPConnection):
+    """http.client speaking to an AF_UNIX listener.
+
+    http.client has no unix transport and every other test config binds
+    host:port, so this exists only for the file/shm suites: the whole point
+    of the hardening is that a unix socket peer keeps the privileged
+    File/Path/Shm inputs that a TCP peer is denied, and that cannot be
+    exercised over TCP by definition.
+    """
+
+    def __init__(self, socket_path, timeout=30):
+        super().__init__("localhost", timeout=timeout)
+        self.socket_path = socket_path
+
+    def connect(self):
+        s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+        s.settimeout(self.timeout)
+        s.connect(self.socket_path)
+        self.sock = s
+
+
+def unix_socket_connect(socket_path):
+    """Connect to a unix socket and close it again, raising if it is not there.
+
+    Readiness probe: Rspamd Startup Check only pings a TCP port, so a worker
+    that also binds a unix socket needs its own barrier.
+
+    Example:
+    | Wait Until Keyword Succeeds | 10x | 0.2s | Unix Socket Connect | /tmp/x.sock |
+    """
+    s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+    s.settimeout(5)
+    try:
+        s.connect(socket_path)
+    finally:
+        s.close()
+
+
+def Scan_File_Over_Unix_Socket(socket_path, filename, **headers):
+    """Like Scan_File but over a unix socket; sets ${SCAN_RESULT}.
+
+    Example:
+    | Scan File Over Unix Socket | ${sock} | /dev/null | File=${msg} |
+    """
+    headers["Queue-Id"] = BuiltIn().get_variable_value("${TEST_NAME}")
+    c = _UnixHTTPConnection(socket_path)
+    try:
+        c.request("POST", "/checkv2", open(filename, "rb"), headers)
+        r = c.getresponse()
+        status = r.status
+        body = r.read().decode('utf-8', errors='replace')
+    finally:
+        c.close()
+    assert status == 200, "Expected HTTP 200 but got %d: %s" % (status, body)
+    d = json.JSONDecoder(strict=True).decode(body)
+    BuiltIn().set_test_variable("${SCAN_RESULT}", d)
+    return
+
+
+def _shm_object_dir():
+    """Directory backing POSIX shared memory names, or None.
+
+    rspamd resolves an `Shm` value with shm_open() only where cmake found
+    POSIX shared memory to be sane, which is Linux (see HAVE_SANE_SHMEM);
+    everywhere else the value is an ordinary path handed to open(2). glibc
+    maps a shm name onto /dev/shm/<name>, so the object can be created as a
+    plain file there without any third party module.
+    """
+    if sys.platform.startswith('linux'):
+        for d in ('/dev/shm', '/run/shm'):
+            if os.path.isdir(d):
+                return d
+    return None
+
+
+def create_shm_payload(content=None, size=None):
+    """Create an object that an `Shm` request header can name.
+
+    Returns [name, path]: `name` goes into the header, `path` is what
+    Remove Shm Payload has to unlink. Give either the exact `content` or a
+    `size` in bytes of filler text.
+
+    Example:
+    | ${name} | ${path} = | Create Shm Payload | size=200000 |
+    """
+    if content is None:
+        nbytes = int(size)
+        content = (("X" * 63 + "\n") * (nbytes // 64 + 1))[:nbytes]
+    data = content if isinstance(content, bytes) else content.encode('utf-8')
+    uniq = "rspamd-fshm-%016x" % random.getrandbits(64)
+    shmdir = _shm_object_dir()
+    if shmdir:
+        path = os.path.join(shmdir, uniq)
+        name = "/" + uniq
+    else:
+        path = os.path.join(tempfile.gettempdir(), uniq)
+        name = path
+    with open(path, "wb") as f:
+        f.write(data)
+    # The daemon may run as another user (nobody in CI)
+    os.chmod(path, 0o644)
+    return [name, path]
+
+
+def remove_shm_payload(path):
+    """Unlink an object made by Create Shm Payload; never fails."""
+    try:
+        os.unlink(path)
+    except OSError:
+        pass
+
+
+def write_readable_file(path, content):
+    """Write `content` to `path` and make it world readable; return the path.
+
+    Robot's Create File obeys the umask and the daemon may run as another
+    user (nobody in CI), so the mode is set explicitly here.
+    """
+    with open(path, "w") as f:
+        f.write(content)
+    os.chmod(path, 0o644)
+    return path
+
+
+def write_filler_file(path, size):
+    """Write `size` bytes of printable filler to `path` and return the path.
+
+    Used where the content is irrelevant and only the size matters, e.g. for
+    the max_message limit on file inputs. Robot's Create File would need the
+    whole payload as a variable first.
+    """
+    nbytes = int(size)
+    with open(path, "wb") as f:
+        chunk = (b"X" * 63 + b"\n") * 1024
+        written = 0
+        while written < nbytes:
+            piece = chunk[:min(len(chunk), nbytes - written)]
+            f.write(piece)
+            written += len(piece)
+    os.chmod(path, 0o644)
+    return path
+
+
+_PENDING_SOCKETS = []
+
+
+def open_pending_connections(addr, port, count, path="/checkv2"):
+    """Open connections that are accepted but whose body never arrives.
+
+    A complete request head announcing a body is sent and the body is then
+    withheld, which is exactly the "accepted and body-pending" state the
+    admission limits are meant to count. The sockets are kept in a module
+    level list so Close Pending Connections can release them from a teardown
+    even after a failure.
+
+    Example:
+    | Open Pending Connections | 127.0.0.1 | ${port} | 2 |
+    """
+    head = ("POST %s HTTP/1.1\r\nHost: %s\r\nContent-Length: 4096\r\n"
+            "Connection: close\r\n\r\n" % (path, addr)).encode()
+    opened = 0
+    for _ in range(int(count)):
+        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        s.settimeout(10)
+        s.connect((addr, int(port)))
+        s.sendall(head)
+        _PENDING_SOCKETS.append(s)
+        opened += 1
+    return opened
+
+
+def close_pending_connections():
+    """Close every socket opened by Open Pending Connections."""
+    closed = 0
+    while _PENDING_SOCKETS:
+        s = _PENDING_SOCKETS.pop()
+        try:
+            s.close()
+            closed += 1
+        except OSError:
+            pass
+    return closed
+
+
+def connection_admitted(addr, port, timeout=5):
+    """True when addr:port serves a request, False when the limit refuses it.
+
+    Over its admission limit rspamd still accepts the connection -- the
+    listen watcher is level triggered, so merely leaving it in the backlog
+    would spin the worker -- and closes it at once. From the client side that
+    is a successful connect followed by an immediate EOF or reset.
+    """
+    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+    s.settimeout(float(timeout))
+    req = ("GET /ping HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n"
+           % addr).encode()
+    try:
+        s.connect((addr, int(port)))
+        s.sendall(req)
+        data = s.recv(64)
+    except socket.timeout:
+        # Admitted but not answered in time: not a refusal, and the caller's
+        # own retry loop is the right place to deal with it.
+        return True
+    except OSError:
+        return False
+    finally:
+        s.close()
+    return data != b""
+
+
 def _build_multipart(boundary, metadata_json, message_bytes):
     """Build a multipart/form-data body with metadata and message parts."""
     body = b""
diff --git a/test/functional/lua/file_shm_probe.lua b/test/functional/lua/file_shm_probe.lua
new file mode 100644 (file)
index 0000000..08db6be
--- /dev/null
@@ -0,0 +1,37 @@
+-- Reports which privileged message source controls actually reached this
+-- scanner as request headers.
+--
+-- File/Path/Shm/Shm-Offset/Shm-Length are hop-by-hop controls that
+-- rspamd_proxy must strip from a client request and regenerate itself, so a
+-- scanner sitting behind a proxy is the only place where "did the client's
+-- value survive?" can be observed directly.
+local privileged_headers = {'File', 'Path', 'Shm', 'Shm-Offset', 'Shm-Length'}
+
+rspamd_config:register_symbol({
+  name = 'FILE_SHM_PROBE',
+  score = 0.0,
+  callback = function(task)
+    local seen = {}
+
+    for _, hname in ipairs(privileged_headers) do
+      local hvalue = task:get_request_header(hname)
+      if hvalue then
+        seen[#seen + 1] = hname .. '=' .. tostring(hvalue)
+      end
+    end
+
+    if #seen == 0 then
+      return true, 'none'
+    end
+
+    return true, table.concat(seen, ';')
+  end
+})
+
+rspamd_config:register_symbol({
+  name = 'SIMPLE_TEST',
+  score = 1.0,
+  callback = function()
+    return true, 'Fires always'
+  end
+})
index 6f3346057a6dd8fe2030920d2686a363938683c7..c18899531dce98342c0c3bfbe92891d5205043fa 100644 (file)
@@ -50,6 +50,7 @@
 #include "rspamd_cxx_unit_compression.hxx"
 #include "rspamd_cxx_unit_tokenizer.hxx"
 #include "rspamd_cxx_unit_http_timeout.hxx"
+#include "rspamd_cxx_unit_task_input.hxx"
 
 static gboolean verbose = false;
 static const GOptionEntry entries[] =
diff --git a/test/rspamd_cxx_unit_task_input.hxx b/test/rspamd_cxx_unit_task_input.hxx
new file mode 100644 (file)
index 0000000..33e506b
--- /dev/null
@@ -0,0 +1,999 @@
+/*
+ * Copyright 2026 Vsevolod Stakhov
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * Regression tests for the privileged message source path: the windowed
+ * segment reader `rspamd_shmem_segment_map` and the two task helpers that
+ * gate it.
+ *
+ * The properties that are pinned down here are the ones that are easy to
+ * regress silently:
+ *
+ *   - only the requested window is ever mapped, so a 64 byte payload inside
+ *     a multi megabyte object never maps that object;
+ *   - the page alignment arithmetic returns exactly the requested slice,
+ *     including at offsets that are deliberately not page aligned;
+ *   - offset and length are validated *together*, so neither a wrapping sum
+ *     nor an out of range offset can produce a range outside the object;
+ *   - the payload handed to the caller is a private snapshot, therefore the
+ *     client can resize the backing object afterwards without the parser
+ *     ever seeing memory that can fault;
+ *   - the name is sanitised before any syscall touches it and an overlong
+ *     name is refused rather than silently truncated;
+ *   - `Filename` is *not* a privileged control, whereas `File`, `Path`,
+ *     `Shm`, `Shm-Offset` and `Shm-Length` are, case insensitively.
+ *
+ * Whether the backing object is a POSIX shared memory object or an ordinary
+ * file is a build time property (HAVE_SANE_SHMEM), and the implementation
+ * picks the matching syscall, so the fixture below creates whichever kind
+ * this build actually opens. Both kinds are unlinked from a destructor, so
+ * a failing assertion cannot leave anything behind.
+ */
+
+#ifndef RSPAMD_CXX_UNIT_TASK_INPUT_HXX
+#define RSPAMD_CXX_UNIT_TASK_INPUT_HXX
+
+#define DOCTEST_CONFIG_IMPLEMENTATION_IN_DLL
+#include "doctest/doctest.h"
+
+#include "config.h"
+#include "libserver/task.h"
+#include "libutil/mem_pool.h"
+
+#include <fcntl.h>
+#include <sys/mman.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+#include <climits>
+#include <cstdlib>
+#include <cstring>
+#include <string>
+#include <vector>
+
+namespace rspamd_task_input_test {
+
+/*
+ * The overlong name test below pads a path up to PATH_MAX - 1 bytes, and the
+ * kernel applies that limit to the *resolved* path, so a symlinked temporary
+ * directory (as on macOS, where TMPDIR lives under /var -> private/var) has to
+ * be resolved upfront or the padded name would not be openable at all.
+ */
+static const std::string &
+test_tmp_dir(void)
+{
+       static const std::string dir = []() -> std::string {
+               char resolved[PATH_MAX];
+               const char *tmp = g_get_tmp_dir();
+
+               if (tmp != nullptr && realpath(tmp, resolved) != nullptr) {
+                       return std::string(resolved);
+               }
+
+               return tmp != nullptr ? std::string(tmp) : std::string("/tmp");
+       }();
+
+       return dir;
+}
+
+static gsize
+test_page_size(void)
+{
+       long ps = sysconf(_SC_PAGESIZE);
+
+       if (ps <= 0) {
+               return 4096;
+       }
+
+       return (gsize) ps;
+}
+
+/*
+ * A deterministic, benign filler: byte at absolute offset `off` is
+ * 'A' + off % 26. The period is coprime with any sane page size, so an
+ * off-by-one in the alignment arithmetic always changes the slice.
+ */
+static std::string
+pattern_slice(gsize off, gsize len)
+{
+       std::string res;
+
+       res.reserve(len);
+
+       for (gsize i = 0; i < len; i++) {
+               res.push_back((char) ('A' + (int) ((off + i) % 26)));
+       }
+
+       return res;
+}
+
+static rspamd_ftok_t
+ftok_of(const std::string &s)
+{
+       rspamd_ftok_t tok;
+
+       tok.begin = s.data();
+       tok.len = s.size();
+
+       return tok;
+}
+
+/*
+ * Owns one backing object for the duration of a test case and removes it
+ * from the destructor, so an assertion that throws still cleans up.
+ */
+class backing_object {
+public:
+       backing_object(const std::string &tag, gsize size)
+       {
+               obj_name = make_name(tag);
+
+#ifdef HAVE_SANE_SHMEM
+               fd = shm_open(obj_name.c_str(), O_RDWR | O_CREAT | O_EXCL, 0600);
+#else
+               fd = open(obj_name.c_str(), O_RDWR | O_CREAT | O_EXCL, 0600);
+#endif
+
+               if (fd == -1) {
+                       return;
+               }
+
+               if (!resize(size)) {
+                       return;
+               }
+
+               ok = fill_pattern();
+       }
+
+       backing_object(const backing_object &) = delete;
+       backing_object &operator=(const backing_object &) = delete;
+
+       ~backing_object()
+       {
+               if (fd != -1) {
+                       close(fd);
+               }
+
+#ifdef HAVE_SANE_SHMEM
+               shm_unlink(obj_name.c_str());
+#else
+               unlink(obj_name.c_str());
+#endif
+       }
+
+       bool valid() const
+       {
+               return ok;
+       }
+
+       const std::string &name() const
+       {
+               return obj_name;
+       }
+
+       gsize size() const
+       {
+               return cur_size;
+       }
+
+       bool resize(gsize new_size)
+       {
+               if (ftruncate(fd, (off_t) new_size) == -1) {
+                       return false;
+               }
+
+               cur_size = new_size;
+
+               return true;
+       }
+
+       /* Rewrites the whole object with the canonical pattern */
+       bool fill_pattern()
+       {
+               auto data = pattern_slice(0, cur_size);
+
+               return write_all(data);
+       }
+
+       /* Rewrites the whole object with a single repeated byte */
+       bool fill_with(char c)
+       {
+               std::string data(cur_size, c);
+
+               return write_all(data);
+       }
+
+private:
+       static std::string make_name(const std::string &tag)
+       {
+               auto uniq = tag + "_" + std::to_string((long) getpid());
+
+#ifdef HAVE_SANE_SHMEM
+               return "/rspamd_test_input_" + uniq;
+#else
+               return test_tmp_dir() + "/rspamd_test_input_" + uniq;
+#endif
+       }
+
+       bool write_all(const std::string &data)
+       {
+               gsize total = 0;
+
+               if (lseek(fd, 0, SEEK_SET) != 0) {
+                       return false;
+               }
+
+               while (total < data.size()) {
+                       ssize_t r = write(fd, data.data() + total, data.size() - total);
+
+                       if (r > 0) {
+                               total += (gsize) r;
+                       }
+                       else if (r == -1 && errno == EINTR) {
+                               continue;
+                       }
+                       else {
+                               return false;
+                       }
+               }
+
+               return true;
+       }
+
+       std::string obj_name;
+       gsize cur_size = 0;
+       int fd = -1;
+       bool ok = false;
+};
+
+/*
+ * Owns the pool that the snapshots are allocated from plus the last GError,
+ * so that neither leaks when an assertion fails.
+ */
+class segment_mapper {
+public:
+       segment_mapper()
+       {
+               pool = rspamd_mempool_new(rspamd_mempool_suggest_size(), "task_input", 0);
+       }
+
+       segment_mapper(const segment_mapper &) = delete;
+       segment_mapper &operator=(const segment_mapper &) = delete;
+
+       ~segment_mapper()
+       {
+               clear_error();
+               rspamd_mempool_delete(pool);
+       }
+
+       struct rspamd_shmem_segment *map_tok(const rspamd_ftok_t *name_tok,
+                                                                                const char *offset,
+                                                                                const char *length,
+                                                                                gsize max_size = 0)
+       {
+               std::string off_str, len_str;
+               rspamd_ftok_t off_tok, len_tok;
+
+               if (offset != nullptr) {
+                       off_str.assign(offset);
+                       off_tok = ftok_of(off_str);
+               }
+
+               if (length != nullptr) {
+                       len_str.assign(length);
+                       len_tok = ftok_of(len_str);
+               }
+
+               clear_error();
+
+               return rspamd_shmem_segment_map(pool, name_tok,
+                                                                               offset != nullptr ? &off_tok : nullptr,
+                                                                               length != nullptr ? &len_tok : nullptr,
+                                                                               max_size, &err);
+       }
+
+       struct rspamd_shmem_segment *map(const std::string &name,
+                                                                        const char *offset,
+                                                                        const char *length,
+                                                                        gsize max_size = 0)
+       {
+               auto name_tok = ftok_of(name);
+
+               return map_tok(&name_tok, offset, length, max_size);
+       }
+
+       GError *error() const
+       {
+               return err;
+       }
+
+       std::string error_message() const
+       {
+               return err != nullptr && err->message != nullptr ? std::string(err->message)
+                                                                                                                : std::string();
+       }
+
+private:
+       void clear_error()
+       {
+               if (err != nullptr) {
+                       g_error_free(err);
+                       err = nullptr;
+               }
+       }
+
+       rspamd_mempool_t *pool;
+       GError *err = nullptr;
+};
+
+/* Every successful call must leave neither a mapping nor a descriptor behind */
+static void
+check_no_mapping_retained(const struct rspamd_shmem_segment *seg)
+{
+       CHECK(seg->map == nullptr);
+       CHECK(seg->map_len == 0);
+       CHECK(seg->fd == -1);
+}
+
+static std::string
+segment_payload(const struct rspamd_shmem_segment *seg)
+{
+       return std::string(seg->data, seg->data_len);
+}
+
+/*
+ * Offset, length and payload folded into one comparable string, so that a
+ * failure inside a loop names the offending window instead of just printing
+ * two numbers
+ */
+static std::string
+describe_window(gsize off, gsize len, const std::string &payload)
+{
+       return std::to_string(off) + "+" + std::to_string(len) + ":" + payload;
+}
+
+static std::string
+describe_segment(const struct rspamd_shmem_segment *seg)
+{
+       return describe_window(seg->offset, seg->data_len, segment_payload(seg));
+}
+
+/* Likewise for the privileged header classification loops */
+static std::string
+classify_header(const char *hdr, struct rspamd_task *task)
+{
+       return std::string(hdr) + " -> " +
+                  (rspamd_task_has_file_shm_input(task) ? "privileged" : "benign");
+}
+
+class task_holder {
+public:
+       task_holder()
+       {
+               pool = rspamd_mempool_new(rspamd_mempool_suggest_size(), "task_input_task", 0);
+               task = rspamd_task_new(nullptr, nullptr, pool, nullptr, nullptr, FALSE);
+       }
+
+       task_holder(const task_holder &) = delete;
+       task_holder &operator=(const task_holder &) = delete;
+
+       ~task_holder()
+       {
+               rspamd_task_free(task);
+               rspamd_mempool_delete(pool);
+       }
+
+       struct rspamd_task *get() const
+       {
+               return task;
+       }
+
+       void add_request_header(const char *name, const char *value)
+       {
+               auto *n = (rspamd_ftok_t *) rspamd_mempool_alloc(task->task_pool,
+                                                                                                                sizeof(rspamd_ftok_t));
+               auto *v = (rspamd_ftok_t *) rspamd_mempool_alloc(task->task_pool,
+                                                                                                                sizeof(rspamd_ftok_t));
+
+               n->begin = rspamd_mempool_strdup(task->task_pool, name);
+               n->len = strlen(name);
+               v->begin = rspamd_mempool_strdup(task->task_pool, value);
+               v->len = strlen(value);
+
+               rspamd_task_add_request_header(task, n, v);
+       }
+
+private:
+       rspamd_mempool_t *pool;
+       struct rspamd_task *task;
+};
+
+}// namespace rspamd_task_input_test
+
+TEST_SUITE("task privileged input")
+{
+       using namespace rspamd_task_input_test;
+
+       TEST_CASE("small window of a huge object retains no mapping")
+       {
+               /* Substantially larger than any page size */
+               constexpr gsize obj_size = 4 * 1024 * 1024;
+               constexpr gsize win_off = 1000;
+               constexpr gsize win_len = 64;
+
+               backing_object obj("small_window", obj_size);
+               REQUIRE(obj.valid());
+
+               segment_mapper mapper;
+               auto *seg = mapper.map(obj.name(), "1000", "64");
+
+               REQUIRE(seg != nullptr);
+               CHECK(mapper.error() == nullptr);
+               CHECK(seg->data_len == win_len);
+               CHECK(seg->offset == win_off);
+               CHECK(segment_payload(seg) == pattern_slice(win_off, win_len));
+
+               /*
+                * The whole point of the windowed reader: no mapping of the 4 MiB
+                * object (nor any other mapping) may outlive the call
+                */
+               check_no_mapping_retained(seg);
+       }
+
+       TEST_CASE("page aligned window returns the exact requested slice")
+       {
+               const gsize page = test_page_size();
+               const gsize obj_size = 8 * page;
+               constexpr gsize win_len = 137;
+
+               backing_object obj("awkward_offsets", obj_size);
+               REQUIRE(obj.valid());
+
+               segment_mapper mapper;
+
+               const std::vector<gsize> offsets = {
+                       0,
+                       1,
+                       page - 1,
+                       page,
+                       page + 1,
+                       3 * page + 17,
+                       obj_size - win_len,
+               };
+
+               for (auto off: offsets) {
+                       auto *seg = mapper.map(obj.name(), std::to_string(off).c_str(),
+                                                                  std::to_string(win_len).c_str());
+
+                       REQUIRE(seg != nullptr);
+                       CHECK(mapper.error() == nullptr);
+                       /* One assertion so that a failure names the offending offset */
+                       CHECK(describe_segment(seg) ==
+                                 describe_window(off, win_len, pattern_slice(off, win_len)));
+                       check_no_mapping_retained(seg);
+               }
+       }
+
+       TEST_CASE("zero length window maps nothing")
+       {
+               const gsize page = test_page_size();
+
+               backing_object obj("zero_length", 4 * page);
+               REQUIRE(obj.valid());
+
+               segment_mapper mapper;
+
+               SUBCASE("explicit zero length")
+               {
+                       auto *seg = mapper.map(obj.name(), "0", "0");
+
+                       REQUIRE(seg != nullptr);
+                       CHECK(mapper.error() == nullptr);
+                       CHECK(seg->data_len == 0);
+                       REQUIRE(seg->data != nullptr);
+                       CHECK(seg->data[0] == '\0');
+                       check_no_mapping_retained(seg);
+               }
+
+               SUBCASE("explicit zero length at a non zero offset")
+               {
+                       auto *seg = mapper.map(obj.name(), "1234", "0");
+
+                       REQUIRE(seg != nullptr);
+                       CHECK(mapper.error() == nullptr);
+                       CHECK(seg->data_len == 0);
+                       CHECK(seg->offset == 1234);
+                       check_no_mapping_retained(seg);
+               }
+
+               SUBCASE("offset at the very end without a length header")
+               {
+                       /* Defaults to st_size - offset, i.e. zero, and maps nothing */
+                       auto *seg = mapper.map(obj.name(),
+                                                                  std::to_string(obj.size()).c_str(), nullptr);
+
+                       REQUIRE(seg != nullptr);
+                       CHECK(mapper.error() == nullptr);
+                       CHECK(seg->data_len == 0);
+                       check_no_mapping_retained(seg);
+               }
+       }
+
+       TEST_CASE("max_size is enforced on the selected length")
+       {
+               const gsize page = test_page_size();
+               const gsize obj_size = 4 * page;
+               constexpr gsize max_size = 128;
+
+               backing_object obj("max_size", obj_size);
+               REQUIRE(obj.valid());
+
+               segment_mapper mapper;
+
+               SUBCASE("the whole object is refused when it exceeds max_size")
+               {
+                       /*
+                        * Without a length header the selected length is the whole object,
+                        * which is what max_size is compared against
+                        */
+                       auto *seg = mapper.map(obj.name(), nullptr, nullptr, max_size);
+
+                       CHECK(seg == nullptr);
+                       CHECK(mapper.error() != nullptr);
+               }
+
+               SUBCASE("an explicit length above max_size is refused")
+               {
+                       auto *seg = mapper.map(obj.name(), "0",
+                                                                  std::to_string(max_size + 1).c_str(), max_size);
+
+                       CHECK(seg == nullptr);
+                       CHECK(mapper.error() != nullptr);
+               }
+
+               SUBCASE("a small window of a large object still succeeds")
+               {
+                       /*
+                        * max_size bounds the payload, not the backing object: this is the
+                        * whole reason for mapping a window instead of the object
+                        */
+                       auto *seg = mapper.map(obj.name(), "4096", "64", max_size);
+
+                       REQUIRE(seg != nullptr);
+                       CHECK(mapper.error() == nullptr);
+                       CHECK(seg->data_len == 64);
+                       CHECK(segment_payload(seg) == pattern_slice(4096, 64));
+                       check_no_mapping_retained(seg);
+               }
+
+               SUBCASE("a length of exactly max_size is accepted")
+               {
+                       auto *seg = mapper.map(obj.name(), "0",
+                                                                  std::to_string(max_size).c_str(), max_size);
+
+                       REQUIRE(seg != nullptr);
+                       CHECK(mapper.error() == nullptr);
+                       CHECK(seg->data_len == max_size);
+                       CHECK(segment_payload(seg) == pattern_slice(0, max_size));
+               }
+
+               SUBCASE("max_size of zero means unlimited")
+               {
+                       auto *seg = mapper.map(obj.name(), nullptr, nullptr, 0);
+
+                       REQUIRE(seg != nullptr);
+                       CHECK(mapper.error() == nullptr);
+                       CHECK(seg->data_len == obj_size);
+                       check_no_mapping_retained(seg);
+               }
+       }
+
+       TEST_CASE("offset and length are validated together")
+       {
+               const gsize obj_size = 4096;
+
+               backing_object obj("offset_length", obj_size);
+               REQUIRE(obj.valid());
+
+               segment_mapper mapper;
+
+               SUBCASE("offset beyond the end of the object")
+               {
+                       auto *seg = mapper.map(obj.name(),
+                                                                  std::to_string(obj_size + 1).c_str(), nullptr);
+
+                       CHECK(seg == nullptr);
+                       CHECK(mapper.error() != nullptr);
+               }
+
+               SUBCASE("length running past the end of the object")
+               {
+                       /* Both fit on their own, their sum does not */
+                       auto *seg = mapper.map(obj.name(), "4000", "1000");
+
+                       CHECK(seg == nullptr);
+                       CHECK(mapper.error() != nullptr);
+               }
+
+               SUBCASE("length one byte past the end of the object")
+               {
+                       auto *seg = mapper.map(obj.name(), "4000",
+                                                                  std::to_string(obj_size - 4000 + 1).c_str());
+
+                       CHECK(seg == nullptr);
+                       CHECK(mapper.error() != nullptr);
+               }
+
+               SUBCASE("length exactly up to the end of the object is accepted")
+               {
+                       auto *seg = mapper.map(obj.name(), "4000",
+                                                                  std::to_string(obj_size - 4000).c_str());
+
+                       REQUIRE(seg != nullptr);
+                       CHECK(mapper.error() == nullptr);
+                       CHECK(seg->data_len == obj_size - 4000);
+                       CHECK(segment_payload(seg) == pattern_slice(4000, obj_size - 4000));
+               }
+
+               SUBCASE("offset near SIZE_MAX")
+               {
+                       auto *seg = mapper.map(obj.name(), "18446744073709551615", "16");
+
+                       CHECK(seg == nullptr);
+                       CHECK(mapper.error() != nullptr);
+               }
+
+               SUBCASE("offset plus length wrapping around SIZE_MAX")
+               {
+                       /*
+                        * A naive `offset + length > st_size` would wrap here and let the
+                        * request through; the combined check must not
+                        */
+                       auto *seg = mapper.map(obj.name(), "4000", "18446744073709551615");
+
+                       CHECK(seg == nullptr);
+                       CHECK(mapper.error() != nullptr);
+               }
+
+               SUBCASE("non numeric offset and length")
+               {
+                       auto *seg = mapper.map(obj.name(), "not-a-number", "16");
+
+                       CHECK(seg == nullptr);
+                       CHECK(mapper.error() != nullptr);
+
+                       seg = mapper.map(obj.name(), "0", "not-a-number");
+
+                       CHECK(seg == nullptr);
+                       CHECK(mapper.error() != nullptr);
+               }
+
+               SUBCASE("empty object is refused")
+               {
+                       backing_object empty_obj("empty", 0);
+                       REQUIRE(empty_obj.valid());
+
+                       auto *seg = mapper.map(empty_obj.name(), nullptr, nullptr);
+
+                       CHECK(seg == nullptr);
+                       CHECK(mapper.error() != nullptr);
+               }
+       }
+
+       TEST_CASE("malformed segment names are rejected before any syscall")
+       {
+               segment_mapper mapper;
+
+               SUBCASE("null name token")
+               {
+                       auto *seg = mapper.map_tok(nullptr, nullptr, nullptr);
+
+                       CHECK(seg == nullptr);
+                       CHECK(mapper.error() != nullptr);
+               }
+
+               SUBCASE("empty name")
+               {
+                       std::string empty;
+                       auto tok = ftok_of(empty);
+                       auto *seg = mapper.map_tok(&tok, nullptr, nullptr);
+
+                       CHECK(seg == nullptr);
+                       CHECK(mapper.error() != nullptr);
+               }
+
+               SUBCASE("name with a newline")
+               {
+                       auto *seg = mapper.map("/rspamd_test_input\nname", nullptr, nullptr);
+
+                       CHECK(seg == nullptr);
+                       CHECK(mapper.error() != nullptr);
+               }
+
+               SUBCASE("name with a low control byte")
+               {
+                       auto *seg = mapper.map(std::string("/rspamd_test_input\x01name"),
+                                                                  nullptr, nullptr);
+
+                       CHECK(seg == nullptr);
+                       CHECK(mapper.error() != nullptr);
+               }
+
+               SUBCASE("name with DEL")
+               {
+                       auto *seg = mapper.map(std::string("/rspamd_test_input\x7fname"),
+                                                                  nullptr, nullptr);
+
+                       CHECK(seg == nullptr);
+                       CHECK(mapper.error() != nullptr);
+               }
+
+               SUBCASE("name with an embedded NUL")
+               {
+                       std::string name("/rspamd_test_input\0name", 23);
+                       auto tok = ftok_of(name);
+                       auto *seg = mapper.map_tok(&tok, nullptr, nullptr);
+
+                       CHECK(seg == nullptr);
+                       CHECK(mapper.error() != nullptr);
+               }
+
+               SUBCASE("name with a url encoded NUL")
+               {
+                       /* The NUL only appears after decoding, it must still be caught */
+                       auto *seg = mapper.map("/rspamd_test_input%00name", nullptr, nullptr);
+
+                       CHECK(seg == nullptr);
+                       CHECK(mapper.error() != nullptr);
+               }
+       }
+
+       TEST_CASE("overlong segment name is rejected rather than truncated")
+       {
+               segment_mapper mapper;
+
+#ifndef HAVE_SANE_SHMEM
+               /*
+                * On the file flavour a path can be padded with redundant slashes,
+                * which lets us build a name whose first PATH_MAX - 1 bytes are a real,
+                * openable path to an object that exists. If the implementation ever
+                * went back to copying with silent truncation, the truncated name would
+                * therefore *succeed*, and this test would go red.
+                */
+               backing_object obj("overlong", 256);
+               REQUIRE(obj.valid());
+
+               const auto &path = obj.name();
+               auto slash = path.rfind('/');
+               REQUIRE(slash != std::string::npos);
+               REQUIRE(path.size() < (gsize) PATH_MAX - 1);
+
+               auto pad = (gsize) PATH_MAX - 1 - path.size();
+               auto max_len_name = path.substr(0, slash + 1) + std::string(pad, '/') +
+                                                       path.substr(slash + 1);
+               REQUIRE(max_len_name.size() == (gsize) PATH_MAX - 1);
+
+               /*
+                * The premise of the second subcase: those PATH_MAX - 1 bytes really do
+                * name the object that has just been created, so acting on a truncated
+                * copy of a longer name would succeed
+                */
+               int probe_fd = open(max_len_name.c_str(), O_RDONLY);
+               REQUIRE(probe_fd != -1);
+               close(probe_fd);
+
+               SUBCASE("a name of exactly PATH_MAX - 1 bytes is still accepted")
+               {
+                       auto *seg = mapper.map(max_len_name, "0", "16");
+
+                       REQUIRE(seg != nullptr);
+                       CHECK(mapper.error() == nullptr);
+                       CHECK(seg->data_len == 16);
+                       CHECK(segment_payload(seg) == pattern_slice(0, 16));
+               }
+
+               SUBCASE("a longer name is refused although its prefix is valid")
+               {
+                       auto overlong = max_len_name + "AAAA";
+                       auto *seg = mapper.map(overlong, "0", "16");
+
+                       CHECK(seg == nullptr);
+                       REQUIRE(mapper.error() != nullptr);
+                       /* Refused by the sanitiser, not by a failing open of a shorter name */
+                       CHECK(mapper.error_message().find("too long") != std::string::npos);
+               }
+#else
+               /*
+                * POSIX shared memory names may not contain a slash and are limited to
+                * NAME_MAX, so a PATH_MAX sized prefix cannot name a real object here.
+                * What can still be asserted is that the refusal comes from the length
+                * check itself, before any syscall sees the name.
+                */
+               SUBCASE("a name longer than PATH_MAX is refused")
+               {
+                       auto overlong = "/rspamd_test_input_" + std::string(PATH_MAX, 'A');
+                       auto *seg = mapper.map(overlong, "0", "16");
+
+                       CHECK(seg == nullptr);
+                       REQUIRE(mapper.error() != nullptr);
+                       CHECK(mapper.error_message().find("too long") != std::string::npos);
+               }
+#endif
+       }
+
+       TEST_CASE("snapshot is stable when the backing object is resized")
+       {
+               const gsize page = test_page_size();
+               const gsize obj_size = 3 * page;
+               const gsize win_off = page + 10;
+               constexpr gsize win_len = 200;
+
+               backing_object obj("resize", obj_size);
+               REQUIRE(obj.valid());
+
+               segment_mapper mapper;
+               auto *seg = mapper.map(obj.name(), std::to_string(win_off).c_str(),
+                                                          std::to_string(win_len).c_str());
+
+               REQUIRE(seg != nullptr);
+               CHECK(seg->data_len == win_len);
+
+               const auto expected = pattern_slice(win_off, win_len);
+               CHECK(segment_payload(seg) == expected);
+               check_no_mapping_retained(seg);
+
+               /* Shrink the object below the window that was just read */
+               REQUIRE(obj.resize(100));
+               CHECK(segment_payload(seg) == expected);
+
+               /* And below the offset of the window, i.e. to nothing at all */
+               REQUIRE(obj.resize(1));
+               CHECK(segment_payload(seg) == expected);
+
+               /* A fresh request for the same window must now be refused */
+               auto *stale = mapper.map(obj.name(), std::to_string(win_off).c_str(),
+                                                                std::to_string(win_len).c_str());
+               CHECK(stale == nullptr);
+               CHECK(mapper.error() != nullptr);
+
+               /* Grow it again and overwrite everything with different bytes */
+               REQUIRE(obj.resize(8 * page));
+               REQUIRE(obj.fill_with('Z'));
+               CHECK(segment_payload(seg) == expected);
+
+               /* The new content is visible only to a new request */
+               auto *fresh = mapper.map(obj.name(), std::to_string(win_off).c_str(),
+                                                                std::to_string(win_len).c_str());
+               REQUIRE(fresh != nullptr);
+               CHECK(segment_payload(fresh) == std::string(win_len, 'Z'));
+               CHECK(segment_payload(seg) == expected);
+       }
+
+       TEST_CASE("rspamd_task_allow_file_shm_input")
+       {
+               CHECK(rspamd_task_allow_file_shm_input(nullptr) == FALSE);
+
+               task_holder th;
+
+               /* Local, non network tasks are trusted by default */
+               CHECK(rspamd_task_allow_file_shm_input(th.get()) == TRUE);
+
+               th.get()->protocol_flags &= ~RSPAMD_TASK_PROTOCOL_FLAG_ALLOW_FILE_SHM_INPUT;
+               CHECK(rspamd_task_allow_file_shm_input(th.get()) == FALSE);
+
+               th.get()->protocol_flags |= RSPAMD_TASK_PROTOCOL_FLAG_ALLOW_FILE_SHM_INPUT;
+               CHECK(rspamd_task_allow_file_shm_input(th.get()) == TRUE);
+
+               /* The answer must not depend on the presence of the headers themselves */
+               th.add_request_header("Shm", "/whatever");
+               th.get()->protocol_flags &= ~RSPAMD_TASK_PROTOCOL_FLAG_ALLOW_FILE_SHM_INPUT;
+               CHECK(rspamd_task_allow_file_shm_input(th.get()) == FALSE);
+       }
+
+       TEST_CASE("rspamd_task_has_file_shm_input")
+       {
+               CHECK(rspamd_task_has_file_shm_input(nullptr) == FALSE);
+
+               SUBCASE("a task without request headers carries no privileged control")
+               {
+                       task_holder th;
+
+                       CHECK(rspamd_task_has_file_shm_input(th.get()) == FALSE);
+               }
+
+               SUBCASE("privileged controls are detected case insensitively")
+               {
+                       const std::vector<const char *> privileged = {
+                               "file",
+                               "File",
+                               "FILE",
+                               "path",
+                               "Path",
+                               "PATH",
+                               "shm",
+                               "Shm",
+                               "SHM",
+                               "shm-offset",
+                               "Shm-Offset",
+                               "SHM-Offset",
+                               "shm-length",
+                               "Shm-Length",
+                               "SHM-LENGTH",
+                       };
+
+                       for (const auto *hdr: privileged) {
+                               task_holder th;
+                               th.add_request_header(hdr, "1");
+
+                               CHECK(classify_header(hdr, th.get()) ==
+                                         std::string(hdr) + " -> privileged");
+                       }
+               }
+
+               SUBCASE("unrelated headers are not privileged controls")
+               {
+                       /*
+                        * `Filename` is merely a label for the message and must never be
+                        * mistaken for the `File` control, no matter how it is spelled
+                        */
+                       const std::vector<const char *> benign = {
+                               "Filename",
+                               "filename",
+                               "FILENAME",
+                               "File-Name",
+                               "Pathname",
+                               "Shmem",
+                               "Shm-Offsets",
+                               "X-Shm",
+                               "Queue-Id",
+                               "From",
+                               "Rcpt",
+                               "Settings-Id",
+                       };
+
+                       for (const auto *hdr: benign) {
+                               task_holder th;
+                               th.add_request_header(hdr, "1");
+
+                               CHECK(classify_header(hdr, th.get()) ==
+                                         std::string(hdr) + " -> benign");
+                       }
+               }
+
+               SUBCASE("a privileged control among benign headers is still detected")
+               {
+                       task_holder th;
+
+                       th.add_request_header("Filename", "message.eml");
+                       th.add_request_header("Queue-Id", "deadbeef");
+                       th.add_request_header("SHM-Offset", "0");
+
+                       CHECK(rspamd_task_has_file_shm_input(th.get()) == TRUE);
+               }
+
+               SUBCASE("detection performs no IO, the object need not exist")
+               {
+                       task_holder th;
+
+                       th.add_request_header("Shm", "/rspamd_test_input_does_not_exist");
+
+                       CHECK(rspamd_task_has_file_shm_input(th.get()) == TRUE);
+               }
+       }
+}
+
+#endif /* RSPAMD_CXX_UNIT_TASK_INPUT_HXX */