Issue Description
(Claude assistance in quoted text)
Summary
The URL-redirector resolution in
crates/spam-filter/src/analysis/url.rsissues serial, inline, uncached GETs at the SMTP DATA stage: a freshreqwest::Clientper fetch (http_get_header, url.rs:297 in v0.16.13), hardcoded 5 s timeout,redirect(Policy::none())with a manual 3-hop walk, gated by theurl-redirectorslookup list (~1076 domains). Compared with the two long-standing implementations of the same feature — rspamdurl_redirectorand SpamAssassinDecodeShortURLs(SA 4.0 core) — four protective layers are missing, and the gap is production-impacting.
Expected Behavior
- provide a dedicated proxy endpoint for URL checks (initially that could be a squid type, but I think the idea should be an endpoint that might perhaps do HTTP 4xx/5xx on bad URLs and HTTP3xx on suspect and HTTP200 on “clean” URLs)
- for each URL in message
- quick return url lookup and if not found in cache
- do a 4xx while background/offline process check remainder URLs found in message that was not not yet checked and cached
Actual Behavior
Production incident (what this costs today)
A Mailchimp campaign (119 KB, ~100
us.list-manage.comlinks — list-manage.com is on the stockurl-redirectorslist) arrived via an upstream relay into a deployment whose mail VLAN has no general internet egress. Every link burned the full 5 s timeout, serially, inside end-of-DATA: ~10 minutes betweenRCPTandqueue.message-queued. The upstream relay (Postfix, default 600 s data-done timeout) hung up at minute 10; Stalwart completed ingest at ~minute 11 into a dead socket; the relay retried forever (message-ingest.duplicateevery cycle) and its queue backed up with 4.4.2 deferrals. Even with working egress, 100 sequential fetches with per-fetch TLS setup is 30–100 s of DATA-stage latency — an outbound-request amplifier and a slow-loris an attacker can trigger with one link-stuffed message.(Side observation: a >3-hop chain sets
URL_REDIRECTOR_NESTED, and url.rs:180 then skips resolution for all remaining URLs in the message — an undocumented implicit cap with odd semantics.)
Reproduction Steps
- Stalwart server setup with inbound from mailrelay (My case ProxMox Mail Gateway)
- Stalwart only has HTTP(S) access to cached copies of the various fingerprint files (refreshed by cache servers that does outbound access) - and the S3 storage, anything else denied.
- receive file with 100s tracking/shortening URLs to be checked in URL DBs.
Stalwart Version
v0.16.x
Installation Method
Binary (Linux)
Database Backend
FoundationDB
Blob Storage
S3-compatible
Search Engine
Internal
Directory Backend
Internal
Additional Context
Proposals (rspamd/SpamAssassin parity)
- Per-message fetch cap —
spam-filter.url-redirect.max-urls(rspamd:max_urls = 5; SA:max_short_urls = 10). When exceeded: tag (e.g.URL_REDIRECTOR_MANY) and stop fetching;REDIRECTOR_URLtagging and original-URL DNSBL checks still apply.- Shared cache with TTL — no new dependency needed: the in-memory KV store already backs greylisting with exactly the required primitive (
in_memory_store().key_set(KeyValue::new(key, ...).expires(ttl)),crates/smtp/src/inbound/rcpt.rs:268-294,KV_GREYLISTprefix pattern). New prefix, key = hashed URL, value = resolvedLocationor a status-code marker (negative caching, as SA does), TTL default 1 day, per-hop entries (rspamd ≥4.1 semantics). On shared backends (Redis/FDB) the cache is cluster-wide for free — one fetch per campaign link per day instead of one per recipient. Both prior implementations converged on exactly this (rspamd: Redisrdr:*,expire = 86400; SA: DBIshort_url_cache, TTL 86400, negative results cached as bare status codes).- Best-effort single-flight — rspamd writes a
processingsentinel withSET NX EXso one worker cluster-wide resolves a given URL during a campaign burst. A best-effort sentinel-before-fetch with short TTL collapses duplicate concurrent fetches; a lost race merely duplicates one fetch.- Configurable timeout + proxy + shared client — the 5 s timeout is hardcoded, and no egress proxy is possible: reqwest is pulled in with
default-features = false, features = ["rustls","http2","stream"](crates/common/Cargo.toml,crates/spam-filter/Cargo.toml), which drops reqwest’s defaultsystem-proxyfeature —HTTP_PROXY/HTTPS_PROXYenv vars are silently dead, and noClientBuilder::proxy()is exposed. Network-restricted deployments (spam filter behind a strict egress firewall) currently cannot route these fetches through an egress proxy at all. Proposals:spam-filter.url-redirect.timeout, optionalspam-filter.url-redirect.proxywired toClientBuilder::proxy(), build the client once at spam-filter init instead of per fetch (also restores connection/TLS-session reuse), optional per-message wall-clock budget (rspamd: 8 s). Consider makingdanger_accept_invalid_certs(true)(url.rs:301) configurable, keeping the current default (rspamd’scheck_sslanalog).All of this is fail-open, mirrors two independent implementations that converged on the same design, and reuses existing Stalwart infrastructure (lookup lists, in-memory TTL keys, trc events).
References
- rspamd
url_redirector: URL redirector module | Rspamd Documentation (max_urls = 5,nested_limit = 2,timeout = 8s/http_timeout = 4s, Redisrdr:*TTL 86400,SET NX EXsingle-flight, per-hop cache since 4.1.0)- SpamAssassin
DecodeShortURLs(4.0 core): Mail::SpamAssassin::Plugin::DecodeShortURLs (max_short_urls = 10,max_short_url_redirections = 10, 5 s timeout, DBI cache TTL 86400 + probabilistic autoclean,env_proxy)
I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.
on
I have searched this support forum (open and closed topics) and confirm this is not a duplicate.
on
I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.
on