${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}
--- /dev/null
+*** 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}
--- /dev/null
+*** 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
--- /dev/null
+*** 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
--- /dev/null
+*** 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
--- /dev/null
+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";
--- /dev/null
+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";
--- /dev/null
+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";
--- /dev/null
+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";
--- /dev/null
+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";
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
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""
--- /dev/null
+-- 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
+})
#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[] =
--- /dev/null
+/*
+ * 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 */