From: Vsevolod Stakhov Date: Thu, 30 Jul 2026 18:07:04 +0000 (+0100) Subject: [Test] Cover file and shm input hardening X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=63c84fc2e876689b87315a403bcdb011a149ae51;p=thirdparty%2Frspamd.git [Test] Cover file and shm input hardening 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. --- diff --git a/test/functional/cases/140_proxy.robot b/test/functional/cases/140_proxy.robot index 79b6b1fedc..6c62491394 100644 --- a/test/functional/cases/140_proxy.robot +++ b/test/functional/cases/140_proxy.robot @@ -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 index 0000000000..8449f58ded --- /dev/null +++ b/test/functional/cases/570_file_shm_deny.robot @@ -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: \nTo: \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 index 0000000000..2a85382023 --- /dev/null +++ b/test/functional/cases/571_file_shm_allow.robot @@ -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: \nTo: \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: \nTo: \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 index 0000000000..f38ee2f5e9 --- /dev/null +++ b/test/functional/cases/572_file_shm_proxy.robot @@ -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: \nTo: \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 index 0000000000..7a28f1795d --- /dev/null +++ b/test/functional/cases/573_admission_limits.robot @@ -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 index 0000000000..8ec00177f5 --- /dev/null +++ b/test/functional/configs/admission_limits.conf @@ -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 index 0000000000..518e678f5c --- /dev/null +++ b/test/functional/configs/file_shm_allow.conf @@ -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 index 0000000000..d1bad57647 --- /dev/null +++ b/test/functional/configs/file_shm_backend.conf @@ -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 index 0000000000..1889f8d52a --- /dev/null +++ b/test/functional/configs/file_shm_deny.conf @@ -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 index 0000000000..4b4cad0b95 --- /dev/null +++ b/test/functional/configs/file_shm_proxy.conf @@ -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"; diff --git a/test/functional/lib/rspamd.py b/test/functional/lib/rspamd.py index da7da1c1c7..0a5a0e88b7 100644 --- a/test/functional/lib/rspamd.py +++ b/test/functional/lib/rspamd.py @@ -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/, 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 index 0000000000..08db6bea24 --- /dev/null +++ b/test/functional/lua/file_shm_probe.lua @@ -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 +}) diff --git a/test/rspamd_cxx_unit.cxx b/test/rspamd_cxx_unit.cxx index 6f3346057a..c18899531d 100644 --- a/test/rspamd_cxx_unit.cxx +++ b/test/rspamd_cxx_unit.cxx @@ -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 index 0000000000..33e506b6aa --- /dev/null +++ b/test/rspamd_cxx_unit_task_input.hxx @@ -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 +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +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 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 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 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 */