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 answers for which no route matched. 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 response most applications send is the stylesheet, and that is a static file, answered without routing ever reaching a filter.

They therefore run in AppServlet, after the answer is worked out and before it goes on the wire, which is the point where the paths of routes, static files, and errors all meet. 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 the Cors value allows gains Access-Control-Allow-Origin. With named origins, every response on a covered path also gains Vary: Origin, whether the request carried an allowed Origin, another one, or none, 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 puts on every response on a covered path, whether the request carried an Origin or not, and adds no Vary: Origin, since the answer is the same for every caller. 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 over from CORS. A path with an OPTIONS handler 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, so a large file or a stream(…​) download never sits in memory whole. A flush() from the writer reaches the client compressed and decodable, at a few bytes a flush, so a progressive body does not wait for the writer to return. Its size is not known beforehand, so minBytes does not apply to one and its Content-Length is dropped. The gzip trailer is written only when the writer returns, so a writer that fails before anything is sent still gets a 500. A writer that fails after the response is committed aborts the transfer, as any other stream does (see Request Logging).

Server-Sent Events and raw responses are never compressed. An SSE stream buffered until it is worth deflating is an SSE stream whose events no longer arrive as they are sent. A raw writer owns the bytes it writes by definition. Nor is a type that is already compressed, such as JPEG, PNG, MP4, or a zip archive, or a response that already carries a Content-Encoding. 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, but 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.

HSTS and a Content Security Policy are off until asked for, because either could break a working site and only the application knows whether it would:

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 sets the header on its own response without turning it off everywhere:

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