CORS, Compression, and Security Headers

CORS, compression, and security headers are each named on App rather than registered as a filter. Almost every deployed application turns all three on:

app.cors(Cors.allowOrigin("https://app.example.com").forPath("/api/*"))
   .gzip()
   .securityHeaders();

Each value is copied when it is registered. A Cors, Gzip, or SecurityHeaders changed afterwards changes nothing the application sends, so a value kept in a field cannot reconfigure a running server.

Why not filters, and why not plugins

A beforeRoute or afterRoute filter runs only once a route has matched, and these three have to reach the answers where no route did. A CORS preflight is an OPTIONS nobody registered a handler for. A cross-origin 404 has to carry the CORS headers, or the browser reports a CORS failure instead of the 404 that actually happened. Security headers belong on an error page as much as on any other page. The largest thing most applications send is the stylesheet, and that is a static file, answered before routing gets as far as a filter.

They therefore run in AppServlet, after the answer is worked out and before it goes on the wire, which is where every path meets: routes, static files, and errors alike. A response filter reaches those answers too, and runs before these three. They stay named on App because a filter that runs before them cannot undo them, and because a CORS preflight is answered while the Allow header is worked out, after request filters and before response filters. Each is one named method taking one inert value, the same shape as staticFiles(StaticFiles) and requestLogger(…​). That is deliberately not a plugin registry: a registry of things that configure themselves is the beginning of a container.

Nothing is on until it is named. An App that says none of the three behaves exactly as it did before they existed.

CORS

Cors says which other origins may call the application, and what they may send:

app.cors(Cors.allowOrigin("https://app.example.com", "https://admin.example.com")
        .forPath("/api/*")                  // the default is "/*"
        .allowCredentials()                 // cookies may be sent
        .exposeHeaders("X-Total-Count")     // a script may read this one back
        .maxAge(Duration.ofHours(1)));      // how long a preflight answer lasts

Two things then happen. Every response to a request carrying an Origin this allows gains Access-Control-Allow-Origin, plus Vary: Origin so a shared cache does not hand one origin’s answer to another. A preflight, an OPTIONS carrying Access-Control-Request-Method, is answered on the spot. Access-Control-Allow-Methods comes from the Allow header the router already works out for the path. Name the methods yourself with allowMethods(…​) when the answer should not follow the routing table.

Cors.anyOrigin() answers *, which is right for a public read-only API. It cannot be combined with allowCredentials(). The specification forbids a wildcard plus cookies outright, so the combination throws where it is written rather than failing silently in a browser.

Registering an OPTIONS route of your own takes the preflight back. A path with a handler for the method is answered by that handler, headers and all. A preflight for a path with no routes is still a 404.

CORS is a rule the browser enforces on what a script may read. A caller that is not a browser ignores all of it, so authentication and authorization stay a before-filter’s job.

Compression

gzip() compresses every compressible response the client says it can read:

app.gzip();                                 // the defaults

app.gzip(Gzip.defaults()
        .minBytes(4096)                     // 1024 by default
        .types("text/", "application/json"));

Compression is a transform over the WebResponse, not a wrapper around the servlet response. A body already in memory (text, html, json, bytes, or a rendered template) is compressed there and then, so the length it announces is the length it sends. A stream body is compressed as it is written, which is what keeps a large file or a stream(…​) download out of memory. Its size is not known beforehand, so minBytes does not apply to one and its Content-Length is dropped.

Server-Sent Events and raw responses are never compressed. An SSE stream buffered until it is worth deflating is an SSE stream that no longer arrives, and a raw handler owns its own bytes by definition. Neither is a type that is already compressed, such as JPEG, PNG, MP4, or a zip archive, nor a response something else has already encoded. A static file answered from a .br or .gz sibling is one of those, and passes through untouched rather than being deflated twice.

What caching is told

Every compressible answer carries Vary: Accept-Encoding, whether it ended up compressed or not, so a cache never hands gzipped bytes to a client that cannot read them. A compressed answer’s ETag, the one static files derive from the file, is marked weak. The compressed and the uncompressed body are the same file, and not the same bytes. A browser revalidating with the weak tag still gets its 304.

Security headers

securityHeaders() sends three headers on every response, because there is no application for which their absence is the better answer:

X-Content-Type-Options: nosniff

An upload served as text/plain is not to be re-read as a script.

X-Frame-Options: DENY

Nobody frames this page, which is what stops a clickjack.

Referrer-Policy: strict-origin-when-cross-origin

A path with an id in it does not leak to whatever the user clicks through to.

Two more are off until asked for, because either could break a working site and only the application knows:

app.securityHeaders(SecurityHeaders.defaults()
        .frameOptions("SAMEORIGIN")                 // the app embeds itself
        .hsts(Duration.ofDays(365))                 // once HTTPS is certain
        .contentSecurityPolicy("default-src 'self'")
        .permissionsPolicy("camera=()")
        .header("X-Robots-Tag", "noindex"));        // anything else, same point

HSTS goes out only on a request that already arrived over HTTPS. A browser that learns it over plain HTTP learned it from whoever was in the way. Behind a TLS-terminating proxy, the container therefore has to be told to trust X-Forwarded-Proto, or the header never goes out at all. HSTS is also the one header here that cannot be taken back: a browser told a year holds that for a year, whatever the server says next. Set a short duration first, and lengthen it once nothing on the site needs HTTP.

A response that set one of these headers itself keeps its own value, so the single page that has to be framed says so on its own response without turning the header off everywhere:

app.get("/widget", req -> WebResponse.template("widget")
        .header("X-Frame-Options", "SAMEORIGIN"));