4.3 KiB
ADR-0005: Reject in-app HTTP response compression
Date: 2026-06-28 Status: Accepted
Context
LDR serves its front-end as Vite-built static bundles (~2 MB JS + ~435 KB CSS)
through the custom app_serve_static route in web/app_factory.py. In a
single-process deployment with no reverse proxy these assets are sent
uncompressed, so a proposal (PR #3158, then a re-scoped PR #4832) added
flask-compress to compress
them in-process with zstd/brotli/deflate, restricted to static MIME types.
The change was implemented, adversarially reviewed, and ultimately rejected. Both PRs are closed; nothing was merged.
Why the benefit is marginal
- Localhost is the default and the point of the tool. Over loopback,
transfer is effectively free; compression only spends CPU. Content-hashed
bundles are already
immutable-cached, so a browser fetches them once. - The win only materialises for users self-hosting LDR remotely, for multiple users, with no reverse proxy — a small slice of a "Local" research tool.
- Those same users should run a reverse proxy anyway (for TLS), and nginx /
Caddy then provide compression and a disk-cached compressed artifact for
free — strictly better than re-compressing on every cold request in-process
(
flask-compress'sCOMPRESS_CACHE_BACKENDdefaults toNone, so there is no compressed-output cache).
Why the cost/risk is real
In-app compression bolts an HTTP-correctness surface onto the app. Adversarial review found, and empirically reproduced:
- Malformed
Range/206responses.flask-compresshas no range guard: aRangerequest with a compressibleAccept-Encodingyields a206whoseContent-Rangedescribes the identity (uncompressed) coordinates while the body is compressed (e.g.Content-Range: bytes 0-1023/14000with a 23-byte zstd body). This is malformed per RFC 9110 and corrupts conformant slicing intermediaries: nginx'sslicemodule resets the connection ("unexpected range in slice response"), CloudFront caches corrupt byte math, andcurl -C -/ download-manager resumes break. - Varnish mis-serve. Varnish (default
http_gzip_support=on) deliberately ignoresVary: Accept-Encoding; with the standard "brotli through Varnish" VCL it can cache a brotli body and serve it to a gzip-only client. - CDN re-opens BREACH. Cloudflare and Fastly compress
text/htmlthemselves by default, so behind them the CSRF-token HTML is compressed regardless of an in-app MIME allow-list. (LDR's CSRF token is already per-render masked by Flask-WTF, which is the durable BREACH mitigation — not declining to compress in-process.) - CPU amplification.
app_serve_staticis public and@limiter.exempt; with no compressed-output cache, a client can force repeated compression of the ~2 MB bundle. Pre-change, static serving was a near-zero-CPU file send.
The benefit accrues to a minority; several of the risks affect more setups (anyone behind a slicing CDN, Varnish, or a TLS-terminating CDN). That is a poor trade for a local-first tool.
Decision
Do not add in-app HTTP response compression to the Flask application. Do not
re-introduce flask-compress or an equivalent runtime compressor.
For remote / multi-user deployments, the guidance is to front LDR with a reverse proxy (nginx / Caddy), which handles TLS, compression, and caching — see Deploying behind a reverse proxy.
If asset compression is ever genuinely wanted without a proxy, use
build-time pre-compression instead: have Vite emit .br / .gz artifacts
served as plain static files. That has zero per-request CPU cost, adds no runtime
dependency, and — because the files are served plainly — avoids the
content-negotiation, conditional-request, and byte-range edge cases above.
Consequences
- The codebase keeps a near-zero-CPU static file path and no new dependency
(
flask-compress+ transitivebackports-zstd). - Remote operators get compression from their reverse proxy/CDN, which is where it belongs.
- Two of PR #3158's original items were unrelated and already shipped:
static
Cache-Controlheaders (#3185 / #3207) and removal of the duplicatestyles.csslink (#3207). Only the compression idea is rejected here.