Remote Content Proxy is a security-focused proxy designed for email clients (Thunderbird, Roundcube). It intercepts external resource requests from HTML emails and serves them through a controlled pipeline that validates, sanitizes, and caches content before delivering it to the client.
All external content (images, CSS, fonts, videos) loaded by the email client passes through this proxy, preventing direct contact between the email client and potentially malicious remote servers.
Server-Side Request Forgery is the primary threat model for any server-side proxy.
Domain Blocklist
- Blocks
localhost,*.local,*.internal, and other internal hostnames - Blocks cloud metadata endpoints:
metadata.google.internal,kubernetes.default.svc - Subdomain matching: blocking
evil.comalso blockssub.evil.com - Configurable via
config('proxy.blocked_domains')
IP Validation
- Blocks every range that is not globally routable, from an explicit CIDR list: RFC1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), carrier-grade NAT (100.64.0.0/10), loopback, link-local,
0.0.0.0/8, the documentation and benchmarking ranges, multicast (224.0.0.0/4) and240.0.0.0/4 - Same for IPv6:
::1,::, unique local (fc00::/7), link-local (fe80::/10), site-local, multicast (ff00::/8), the documentation and discard ranges - Blocks the IPv6 transition prefixes that embed an IPv4 address: IPv4-mapped (
::ffff:0:0/96), IPv4-compatible (::/96), NAT64 (64:ff9b::/96), 6to4 (2002::/16), Teredo (2001::/32) - Blocks cloud metadata IPs:
169.254.169.254(AWS),169.254.169.253(Azure) - Explicit blocklist for
127.0.0.1,0.0.0.0,::1,::and others - Configurable via
config('proxy.blocked_ips')andconfig('proxy.blocked_ranges')
IPv6-Mapped IPv4 Detection
- Detects
::ffff:127.0.0.1(dotted-decimal mapped) - Detects
::ffff:7f00:1(hex-mapped) and converts to IPv4 for validation - Detects deprecated
::127.0.0.1(IPv4-compatible) format - Prevents SSRF bypass via IPv6 encoding of private IPv4 addresses
DNS Rebinding Protection
- DNS is resolved once before the HTTP request
- The resolved IP is pinned via
CURLOPT_RESOLVEfor the entire request lifecycle - Prevents time-of-check/time-of-use attacks where DNS changes between validation and fetch
Redirect Validation
- Redirects are never followed by the HTTP client (
allow_redirectsis off); the proxy follows them itself, one hop at a time, up to 5 hops - Each
Location:target (relative ones resolved against the current URL) is resolved and validated against the same SSRF rules as the original URL - The next hop is pinned via
CURLOPT_RESOLVEto the IP that was just validated, so the DNS pinning survives every redirect - Only HTTP/HTTPS redirect protocols allowed (
CURLOPT_REDIR_PROTOCOLS)
Scheme Allowlist
- Only
httpandhttpsschemes are accepted ftp,file,data,javascript,vbscript,phpschemes are blocked- Configurable via
config('proxy.allowed_schemes')
Port Restrictions
- Only ports 80, 443, 8080, and 8443 are allowed
- Prevents the proxy from being used as a port scanner against internal services
- Blocked port attempts are logged (
Log::warning) with hostname and port number for monitoring - Configurable via
config('proxy.allowed_ports') - Requests with no explicit port use the scheme default (80/443) and are always allowed
URL Normalization
- Strips whitespace, null bytes (
\0), and control characters (\x00-\x1F,\x7F) - Does NOT urldecode the URL: percent-encoded bytes reach the remote server as they are
- The whole URL must pass
filter_var(FILTER_VALIDATE_URL), which rejects the confusable forms (a backslash before@, a second:in the authority) where a lenient parser and cURL could disagree on the host
Path and query are not inspected. Once the scheme, the host and the port are settled, nothing in the rest of the URL changes what the proxy connects to, and cURL reads the authority the same way parse_url() does (userinfo tricks such as https://good.com%5c@evil.com/ land on evil.com for both). A query value that happens to contain data: or ../ is the remote server's business and is passed through.
Content-Type Validation
- Allowlist of MIME types: images, CSS, fonts, videos (see
config('proxy.allowed_content_types')) - Server-provided Content-Type is parsed and validated
- If missing or untrusted, magic byte detection is used as fallback
Magic Byte Detection (ContentTypeDetector)
- Verifies actual file content against known binary signatures (JPEG, PNG, GIF, WebP, AVIF, BMP, ICO, WOFF/WOFF2, TTF, OTF, EOT, MP4, QuickTime, WebM, Matroska, Ogg, AVI)
- SVG detection uses strict regex: must start with
<?xml,<!DOCTYPE, or<svgwithin the first 1024 bytes - Prevents content-type spoofing attacks
Size Limits
- Default: 10 MB for images/CSS/fonts, 50 MB for videos
- Configurable via
PROXY_MAX_FILE_SIZEandPROXY_MAX_VIDEO_SIZE - Enforced while downloading, not after: the body goes into a sink capped at the larger of the two limits, and cURL aborts the transfer as soon as the cap is crossed. A server that streams without end, announces a wrong
Content-Lengthor serves a compression bomb (the cap counts decompressed bytes) cannot fill the worker's memory. AContent-Lengthabove the cap is refused before the body starts - The per-type limit is applied to the downloaded body afterwards
SVG Sanitization (enshrined/svg-sanitize)
- Removes
<script>tags and all JavaScript event handlers - Removes external references (remote resources, XLink)
- Blocks XML entity injection (XXE)
- Minifies output
CSS Sanitization (sabberworm/php-css-parser)
CSS is parsed into an Abstract Syntax Tree (AST) and each node is inspected:
| Threat | Action |
|---|---|
@import rules |
Removed — prevents loading external stylesheets |
expression() function |
Rejected — IE JavaScript execution in CSS |
javascript: / vbscript: in url() |
Rejected — script protocol injection |
behavior property |
Stripped — IE HTC component loading |
-moz-binding property |
Stripped — Firefox XBL binding |
External url() (http/https) |
Rewritten to proxy URL (or about:invalid if no base URL configured) |
Protocol-relative url() (//...) |
Rewritten to proxy URL with https: prefix |
Relative/absolute path url() |
Replaced with about:invalid (cannot be resolved) |
data:image/*, data:font/* URIs |
Preserved — safe embedded content |
#fragment references |
Preserved — internal SVG/filter references |
| HTML/script injection in CSS | Rejected — <script>, </script>, <!--, --> |
| Null bytes | Stripped before parsing |
CSS URL Rewriting: When PROXY_BASE_URL is configured, external url() references are rewritten to pass through the proxy instead of being removed:
/* Before */
body { background: url("https://cdn.example.com/bg.png"); }
/* After */
body { background: url("https://your-proxy.com/i/{base64url}"); }This allows CSS sub-resources (background images, fonts) to remain functional while still being proxied through the security pipeline.
SSL/TLS
- Certificate validation is enforced (cURL defaults)
- Only HTTP and HTTPS protocols allowed at the cURL level (
CURLOPT_PROTOCOLS)
Timeout Controls
- Connection timeout: 10s (configurable via
PROXY_CONNECT_TIMEOUT) - Transfer timeout: 10s (configurable via
PROXY_TIMEOUT) - Low-speed detection: transfer aborted if speed drops below 1000 bytes/sec for 10s
Retry with Exponential Backoff
- Up to 3 attempts (configurable) with exponential delays (1s, 2s)
- Only connection failures and 5xx answers are retried; a 4xx or a redirect is handled at once, so a dead link does not hold a worker through the backoff schedule
- Each retry is logged for monitoring
API Key Authentication
- 256-bit cryptographically random keys (64 hex characters)
- Keys are hashed with SHA-256 before storage — the plaintext key is shown once at creation and cannot be retrieved afterwards
- Database compromise does not reveal usable API keys
- Lookup:
hash('sha256', $input)is compared against stored hash - Supports revocation with timestamp
- Multiple authentication methods:
Authorization: Bearer,X-API-Keyheader,?api_key=query parameter - Configurable via
PROXY_REQUIRE_API_KEY
Rate Limiting
- Per API key when the request carries a valid one, per client IP otherwise; an unknown or revoked key does not open a bucket of its own, the attempts count against the IP
- Each key may carry its own allowance (
apikey:create --rate-limit,apikey:limit); a key without one usesPROXY_RATE_LIMIT_MAX_REQUESTS. The window is the same for every key - Default: 100 requests per 60 seconds
- Configurable via
PROXY_RATE_LIMIT_*
Usage Tracking
last_used_attimestamp updated on each valid request- Throttled to max once per 5 minutes to avoid excessive DB writes
- Enables detection of unused/abandoned keys
All responses (success and error) include security headers:
| Header | Value | Purpose |
|---|---|---|
X-Content-Type-Options |
nosniff |
Prevents MIME type sniffing |
X-Frame-Options |
DENY |
Prevents framing/clickjacking |
Content-Security-Policy |
default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; media-src 'self'; font-src 'self' |
Restricts resource loading |
Referrer-Policy |
no-referrer |
Prevents referer header leakage |
Successful responses also include:
Cache-Control: public, max-age=86400, immutablefor client-side caching
- Cache keys are derived from URL hashes (SHA-256)
- Cache bypass via client headers (
Cache-Control: no-cache) is disabled by default to prevent abuse - Configurable via
PROXY_CACHE_RESPECT_CLIENT_HEADERS - Cache stampede protection with lock mechanism
- Automatic cleanup of expired entries
Security events are logged with structured context:
| Event | Level | Details |
|---|---|---|
| Blocked URL | ERROR | URL hash, host, block reason |
| Blocked port | WARNING | Port number, hostname |
| Invalid URL | ERROR | URL hash, validation details |
| Fetch failure | ERROR | URL hash, HTTP status, attempts |
| Auth failure | Middleware | Via Laravel's 401 response |
| Rate limit | Middleware | Via Laravel's rate limiter |
| CSS injection | WARNING | Injection type detected |
| Redirect to blocked URL | ERROR | Original URL, block reason |
| Too many redirects | ERROR | Original URL, hop limit |
URL hashing in logs prevents sensitive data leakage. Full URL logging can be enabled for debugging via PROXY_LOG_FULL_URL=true.
- Port restrictions: Only ports 80, 443, 8080, 8443 are allowed. Resources served on non-standard ports (e.g.
:3000,:8888) will be blocked. Adjustproxy.allowed_portsif needed. - @import in CSS: All
@importrules are stripped. CSS that relies on@importfor loading other stylesheets will lose those imports. - Relative URLs in CSS: Relative and absolute-path URLs (
/images/bg.png,images/bg.png) cannot be resolved without knowing the original base URL. They are replaced withabout:invalid. - SVG remote references: All remote references in SVGs are removed by the sanitizer.
- No WebSocket support: The proxy only handles HTTP/HTTPS requests.
- API key in CSS sub-resources: When CSS URLs are rewritten to proxy URLs, the client (Thunderbird extension) is responsible for adding authentication to those sub-requests.
- API key in the query string:
?api_key=is accepted for clients that cannot set headers, but it ends up in the web server access log and in browser history. PreferAuthorization: BearerorX-API-Keywhenever the client can send a header.
- Always enable API key authentication in production (
PROXY_REQUIRE_API_KEY=true) - Set
PROXY_BASE_URLto your proxy's public URL for CSS URL rewriting to work - Use HTTPS for the proxy itself
- Monitor logs for recurring blocked ports — this may indicate legitimate resources on non-standard ports
- Keep dependencies updated (
enshrined/svg-sanitize,sabberworm/php-css-parser) - Disable full URL logging in production if URLs may contain sensitive data (
PROXY_LOG_FULL_URL=false) - Configure rate limits based on expected usage patterns, with a larger allowance for a key shared by many users (a webmail) than for a personal one
- Review blocked domains and add organization-specific entries
- Disable client cache bypass in production (
PROXY_CACHE_RESPECT_CLIENT_HEADERS=false)