How celld Handles Forward Trusted Headers in Production Deployments

By default, celld ignores X-Forwarded-Host and X-Forwarded-Proto headers to prevent client spoofing, but operators can enable trusted forwarding via --trust-forwarded-headers or CELLD_TRUST_FORWARDED_HEADERS=1 to extract the last comma-separated value from each header.

The celld runtime, developed by Deno, takes a security-first approach to handling forward trusted headers. In production deployments behind reverse proxies or load balancers, applications typically need the original client-facing URL scheme and host. Rather than blindly trusting potentially malicious client headers, celld requires explicit opt-in and implements a strict parsing strategy that assumes upstream proxies replace rather than append to these headers.

Default Behavior: Headers Are Untrusted

Out of the box, celld constructs the effective request URL solely from the HTTP request line. The X-Forwarded-Host and X-Forwarded-Proto headers are completely ignored, preventing clients from manipulating how the application perceives its own URL.

This default is implemented in crates/celld/main.rs inside the request_url function. When trust_forwarded_headers is false, the function falls back to the standard Host header and hardcoded http scheme:

let host = header("host", false).unwrap_or("celld.local");
let scheme = "http";

This ensures that even if a malicious actor sends X-Forwarded-Proto: https to force secure cookie behavior or bypass security checks, celld remains unaffected.

Enabling Trusted Forwarded Headers

Operators activate trusted header processing through one of two mechanisms defined in crates/celld/main/cli.rs:

  1. Command-line flag: --trust-forwarded-headers
  2. Environment variable: CELLD_TRUST_FORWARDED_HEADERS=1

The CLI parsing code handles both sources between lines 23 and 70, storing the result in the Settings struct. This propagates through AppHandle.trust_forwarded_headers to the HTTP handler.

When enabled, celld changes its header parsing strategy significantly.

How celld Parses Forwarded Headers When Trusted

The critical logic resides in crates/celld/main.rs at lines 3129–3146. The request_url function uses a closure-based approach:

fn request_url(parts: &hyper::http::request::Parts, trust_forwarded_headers: bool) -> String {
    let header = |name: &str, take_last: bool| { 
        // Returns Option<String> by parsing header values
        // When take_last is true, splits on comma and returns final element
        ...
    };
    
    let forwarded = |name: &str| {
        trust_forwarded_headers.then(|| header(name, true)).flatten()
    };
    
    let host = forwarded("x-forwarded-host")
        .or_else(|| header("host", false))
        .unwrap_or("celld.local");
        
    let scheme = forwarded("x-forwarded-proto").unwrap_or("http");
    
    // path_and_query comes directly from request-target, never from headers
    format!("{scheme}://{host}{path_and_query}")
}

Key implementation details:

  • take_last: true — When trusting headers, celld extracts the final comma-separated value from multi-valued headers. This assumes a trusted proxy has replaced the entire header value, eliminating any client-supplied prefix.
  • Fallback chain — If forwarded headers are absent, celld degrades gracefully to Host header and http scheme.
  • Path protection — The request-target path and query are always taken directly from the request line, never from headers, preventing additional attack vectors.

Security Requirements for Production

The docs/security.md file (lines 38–46) explicitly documents the deployment prerequisites for enabling this flag. Operators must ensure:

  • A trusted proxy sits in front of celld
  • The proxy replaces both X-Forwarded-Host and X-Forwarded-Proto entirely
  • The proxy does not merely append to existing values, which would leave client-controlled data in earlier comma-separated positions

This design intentionally breaks the de-facto standard of reading the first forwarded value. By reading the last, celld forces infrastructure operators to configure proper header sanitization at the edge.

Practical Deployment Examples

Basic Enablement


# CLI flag approach

celld --bucket s3://my-fleet-bucket --trust-forwarded-headers

# Environment variable approach

export CELLD_TRUST_FORWARDED_HEADERS=1
celld --bucket s3://my-fleet-bucket

Impact on Application Code

When enabled, Durable Object handlers receive the reconstructed public URL:

// With --trust-forwarded-headers
let url = request.url(); // "https://api.example.com/v1/data"

Without the flag, the same code produces:

// Default behavior
let url = request.url(); // "http://celld.local/v1/data"

Replicating celld's Logic

Applications needing to implement identical behavior can reference the internal structure:

// Mirrors celld's request_url implementation
fn effective_url(req: &hyper::Request<hyper::body::Body>, trust: bool) -> String {
    celld::runtime::request_url(req.parts(), trust)
}
File Purpose Key Lines
crates/celld/main/cli.rs Parses CLI flags and env vars 23–70
crates/celld/main.rs Implements request_url header logic 3129–3146
docs/security.md Documents forwarded header policy 38–46
crates/celld/runtime.rs Invokes URL construction per request Runtime integration

Summary

  • Default security: celld completely ignores X-Forwarded-* headers, using only the request line and Host header
  • Opt-in activation: --trust-forwarded-headers or CELLD_TRUST_FORWARDED_HEADERS=1 required in production
  • Last-value parsing: Enabled mode extracts the final comma-separated element, assuming proxy replacement
  • Infrastructure dependency: Requires trusted upstream proxy that fully replaces both headers, never appends
  • Graceful degradation: Missing forwarded headers fall back to standard behavior without failure

Frequently Asked Questions

What happens if I enable --trust-forwarded-headers without a proxy?

Your application becomes vulnerable to host header injection attacks. Clients can set arbitrary X-Forwarded-Host and X-Forwarded-Proto values, causing authentication callbacks, cookie domains, and URL generation to use attacker-controlled values. Only enable this when a trusted proxy sanitizes these headers.

Why does celld use the last value instead of the first?

The first value in a comma-separated forwarded header typically comes from the original client. Subsequent values are added by each proxy hop. By reading the last value, celld ensures that only the final trusted proxy's replacement value is honored—assuming the proxy was configured to replace rather than append. This inverts the typical "first trusted" pattern to enforce stricter infrastructure requirements.

Does celld support Forwarded header (RFC 7239)?

No. As implemented in denoland/celld, only X-Forwarded-Host and X-Forwarded-Proto are parsed. The standardized Forwarded header is not processed even when trust mode is enabled. Applications requiring RFC 7239 support must parse that header manually.

Can I trust only one header (host or proto, but not both)?

No. The trust_forwarded_headers flag is binary—both X-Forwarded-Host and X-Forwarded-Proto are either trusted together or ignored together. This prevents partial trust configurations that could create scheme/host mismatches in URL construction.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →