Skip to main content

REST Security

Denial of service attacks can be alleviated through the maxInput() setting.

Arbitrarily-large input will trigger an exception before causing out-of-memory errors.

The default value for this setting is 100MB.

Since the parsers do not use intermediate DOMs and instead parse directly into Java objects, deeply nested data structures will almost always trigger stack overflow errors long before memory consumption becomes an issue.

However, this is NOT true of the RDF parsers, which build an intermediate DOM.

If parsing RDF, you may want to consider lowering the max-input value above.

CSRF protection: loopback vs general​

Juneau ships two request boundaries in org.apache.juneau.rest.server.filter. Both decide whether a state-changing request came from a page your application served (or from a non-browser client) and refuse it when it came from some other site's page. Neither one authenticates the caller, and neither proves the user meant the action; those need their own mechanisms.

BoundaryUse it when
LoopbackBoundaryThe app is bound to the loopback interface and is reached under one pinned Host (for example 127.0.0.1:8790) over plain HTTP. It pins that single origin and is the stricter of the two.
CsrfBoundaryEverything else: an app reached under a real host name, over HTTPS, behind a proxy, or under more than one name. Nothing is pinned; the request's own scheme and host are the expected origin.

Both are applied by RequestBoundaryFilter. A rejected request is answered directly with a JSON body, a Cache-Control: no-store header, and an X-Request-Boundary header naming the reason (for example ORIGIN_MISMATCH or CSRF_TOKEN_MISSING). The request never reaches application code.

Even without a boundary, the router refuses an override of a safe method (GET, HEAD, OPTIONS, TRACE) to an unsafe one with 405. A boundary still judges any custom-method claim as a write, including one declared @RestOp(read=true).

What CsrfBoundary checks​

On every state-changing request (the method is judged after any ?method= or X-Method, X-HTTP-Method-Override, X-HTTP-Method or X-Method-Override override, so an override cannot carry a write past the checks):

  • Origin, with Sec-Fetch-Site as a fallback, must match the request's own scheme, host and port. A literal null origin is refused.
  • A browser request must also carry a synchronizer token in a request header (X-Csrf-Token by default). The token is read from that header and nowhere else: never a query parameter, never a form field.
  • Origins are compared as normalized scheme, host and port. Default ports are equated (https://app.example.com and https://app.example.com:443 match). IPv6 literals are compared in bracketed form exactly as sent, and nothing else is re-canonicalized, so two spellings of the same address do not match each other. That is deliberate: it fails closed.

Registering the filter​

CsrfBoundary boundary = CsrfBoundary.create()
.allowedHosts("app.example.com")
.build();

servletContext.addFilter("csrf", new RequestBoundaryFilter(boundary))
.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/*");
  • Map the filter at /* so nothing can be reached around it.
  • Register it for REQUEST dispatches only. An ERROR dispatch of a refused write can arrive without the token header and be refused in turn, replacing the error page with a 403, and a FORWARD to an HTML view looks like a page load and can create a session. The default registration in a servlet container, and Spring Boot's FilterRegistrationBean, is already REQUEST only.
  • Register it ahead of any filter that acts on the request on the application's behalf. The one exception is described under proxies below.

By default the filter creates an HTTP session on a safe request that looks like a page load, so that the page it serves can carry a session-scoped token. Turn that off with createSessions(false).

Reverse proxies and forwarded headers​

By default (OriginSource.HOST_HEADER) the request's own origin is request.getScheme() plus the Host header. X-Forwarded-* and Forwarded are never read, so a client cannot spoof them past the boundary.

HTTP/2 has no Host header: the :authority pseudo-header plays that part, and servlet containers surface it only as getServerName() and getServerPort(). A request with no Host whose protocol is exactly HTTP/2.0 is therefore judged on its server name and port in Host's place, under both OriginSources and allowedHosts. A request without Host on any other protocol is still refused with HOST_MISSING. HOST_HEADER is consequently protocol-dependent: it reads the raw Host on HTTP/1.1 and the container's server name on HTTP/2, and the server name is what a container's forwarded-header support rewrites (Tomcat's RemoteIpValve with a host header, Spring's ForwardedHeaderFilter, Jetty's ForwardedRequestCustomizer). On HTTP/2 an X-Forwarded-Host that such support honors is therefore trusted under HOST_HEADER too.

Behind a proxy that terminates TLS, the scheme the app sees is http while the browser's Origin is https. Use OriginSource.CONTAINER and configure the container's own forwarded-header support, so the container rewrites the request before the boundary sees it:

StackSetting
JettyAdd a ForwardedRequestCustomizer to the connector's HttpConfiguration.
TomcatAdd a RemoteIpValve.
Spring Bootserver.forward-headers-strategy=native rewrites before any filter runs, so filter order does not matter.
Spring Bootserver.forward-headers-strategy=framework registers ForwardedHeaderFilter at Ordered.HIGHEST_PRECEDENCE. Register the boundary's filter strictly later, never at HIGHEST_PRECEDENCE itself.
nginxproxy_set_header Host $host; and proxy_set_header X-Forwarded-Proto $scheme;

The boundary cannot see its own filter order. A misordered or misconfigured deployment shows up as ORIGIN_MISMATCH refusals and a one-time warning in the log naming the likely cause (scheme-only or port-only mismatch).

Enable a forwarded-header customizer only behind a proxy that overwrites, rather than appends to, X-Forwarded-*. A browser cannot send those headers, but anything that can reach the connector directly can, and the container then trusts the forwarded host.

WebSocket handshakes​

A WebSocket upgrade is a GET, so the filter lets it through; the bus WebSocket endpoint applies the boundary to the handshake itself. BusWebSockets.register accepts a bus whose BusPolicy boundary is a LoopbackBoundary or a CsrfBoundary, and refuses to register for any other boundary type (it fails closed).

Under a CsrfBoundary:

  • No token can be sent on a handshake, so the control is Origin plus Sec-Fetch-Site.
  • The container's checkOrigin hook runs first. A missing, blank or null Origin, or one that is not a well-formed http or https origin, is refused with HTTP 403 before the upgrade.
  • A well-formed Origin that is cross-site, or does not match the handshake's own origin, is refused after the upgrade: the connection is closed with code 4403 before any frame is sent and before any bus session is touched.
  • The handshake has no server name, so the check judges the raw Host header plus the scheme of the handshake's request URI, whatever OriginSource is set to. ws counts as http and wss as https.
  • A WebSocket over HTTP/2 (RFC 8441, an extended CONNECT) has no Host. When a handshake carries neither Host nor Sec-WebSocket-Key or Upgrade (which mark an RFC 6455 request, and which the container enforces), the authority of its request URI, which is the :authority, stands in for Host. Jetty advertises RFC 8441 by default whenever HTTP/2 is enabled; Tomcat does not support it, so on Tomcat there is no HTTP/2 handshake to judge.
  • Behind a reverse proxy that rewrites Host to an internal name, with no container-side host rewriting, same-origin upgrades are refused. That is accepted behavior. Preserve the original Host at the proxy (in nginx, proxy_set_header Host $host;). If the proxy cannot preserve Host, see Trusting a forwarded origin.
  • Jetty's ForwardedRequestCustomizer rewrites the Host that both ordinary requests and the WebSocket handshake see, using X-Forwarded-Host. With it enabled the forwarded host is trusted for both, so the overwrite-not-append rule above applies to the WebSocket endpoint too. For an HTTP/2 handshake on Jetty this customizer is the only forwarded-header support that applies: it runs at the connector, while the WebSocket upgrade filter runs ahead of any servlet filter, so Spring's ForwardedHeaderFilter never sees the handshake.
  • On Tomcat the handshake's request URI reports ws or wss, normalized as above. RemoteIpValve rewrites that scheme from X-Forwarded-Proto, so a TLS-terminating proxy works. It does not rewrite the Host header the handshake reads, even with the valve's hostHeader set to X-Forwarded-Host. Unlike Jetty's ForwardedRequestCustomizer, a forwarded host is therefore never trusted for the WebSocket endpoint, and the preserve-Host advice above applies.

Trusting a forwarded origin​

By default the boundary never reads X-Forwarded-* or Forwarded itself. A deployment whose proxy rewrites Host and whose container does no forwarded-host handling (no ForwardedRequestCustomizer, or Tomcat without a host-rewriting valve) can opt in on CsrfBoundary:

CsrfBoundary.create()
.trustedForwarding(CsrfBoundary.ForwardedSource.X_FORWARDED) // or FORWARDED (RFC 7239)
.trustedHops(1) // proxies you operate, 1 to 16
.trustedProxies("10.0.12.0/24") // your proxies' addresses; optional
.build();

This is a trust decision, so it is fail-closed throughout:

  • Reachability. Only turn it on when every request reaches the app through proxies that you operate, or set trustedProxies so that peers that are not your proxies are not believed. A peer that can reach the app directly can send any forwarded host it likes.

  • Proxy hygiene. Every proxy you operate must overwrite or append to the forwarded header the way trustedHops assumes, and strip any client-supplied copy. trustedProxies does not replace this: an allow-list cannot detect a proxy that passes a client's header through.

  • One header family is read, never both. The forwarded host and protocol are both required (X-Forwarded-Host plus X-Forwarded-Proto, or Forwarded elements carrying host= and proto=). X-Forwarded-Port is never read: put the port in the host value. A missing, repeated-in-conflict or malformed value is refused with a FORWARDED_* reason (HTTP 403) and a handshake is closed with 4403. A forwarded header whose lines cannot be put in wire order (a handshake that presents the same header under more than one spelling) is refused as FORWARDED_HOST_MALFORMED or FORWARDED_PROTO_MALFORMED.

  • The entry that counts is the one added by your outermost proxy: the nth from the right, with n set by trustedHops. Entries to its left are client-controlled and ignored. Set trustedHops to the number of proxies you run. Too small is safe (the request is refused, or the wrong hop mismatches); too large reaches into client-supplied entries and is not safe.

  • Picking the nth entry from the right assumes the container hands the handshake's repeated header lines to the endpoint in the order they arrived. Jetty and Tomcat both do (each is covered by an integration test); with any other container, confirm this before turning trusted forwarding on.

  • The forwarded origin replaces the container's view; it is never added to it. allowedHosts is checked against the forwarded host.

  • If the container also rewrites forwarded headers, the two must agree: under OriginSource.CONTAINER a disagreement is refused with FORWARDED_CONTAINER_MISMATCH, and a rewritten host that equals an ignored left-hand entry, or that might be one that does not parse (a container may normalize it), is refused too. The cross-check compares hosts only, not ports or schemes.

  • A safe request (GET/HEAD/OPTIONS) on a boundary with no allowedHosts is not judged, so direct health probes without forwarded headers still pass.

  • Optionally, name the proxies. trustedProxies("10.0.12.0/24", "fd00:12::/64") makes the boundary believe the forwarded header only from a connection whose peer address is in one of the ranges. A request from any other peer that carries the header is refused (FORWARDED_PEER_NOT_TRUSTED, 403, or 4403 on a WebSocket). One without the header is judged on its own Host, as if forwarding were off, so allowedHosts still applies. List the addresses your proxies connect from, not your whole network. Host bits below the prefix are ignored (10.0.12.7/24 means 10.0.12.0/24), and a /0 entry (0.0.0.0/0 or ::/0) trusts every peer of that address family. Only the last proxy before the server is checked. trustedHops still decides which entry counts, and an allow-list does not make an overstated trustedHops safe. Requires OriginSource.HOST_HEADER.

    The peer is the address the container reports. The setting is meaningless, or unsafe, where the container rewrites it: Jetty's ForwardedRequestCustomizer (which takes X-Forwarded-For from anyone), Tomcat's RemoteIpValve and Spring Boot's forward-headers-strategy=native, or the PROXY protocol. Use it only where the server sees your proxy's own address.

    WebSockets. A WebSocket handshake carries no peer address in the standard API. On Jetty the boundary reads the one Jetty supplies. On Tomcat there is none, so with trustedProxies set a handshake that carries a forwarded host is always refused, and one without it is judged on its own Host and scheme. Bus sockets then work on Tomcat only if TLS ends at Tomcat (or nothing uses TLS), the proxy preserves Host, and it sends no X-Forwarded-Host on the socket route. Otherwise leave trustedProxies unset on Tomcat.

    Direct callers. With allowedHosts set, a caller that bypasses the proxy is checked on its own Host, so add the backend name it uses (backend) to allowedHosts if it must write. That is safe: a rebound page's Host is the attacker's name.

Non-browser clients​

An unsafe request with neither Origin nor Sec-Fetch-Site is not from a browser page, and the policy set with nonBrowser(...) decides what happens to it. With the default, NonBrowser.ADMIT_NON_SIMPLE, it is admitted without a token only when a browser could not have sent it cross-site without a CORS preflight, which this boundary never answers. For client authors the rule is: send JSON, or send Origin plus the token. Concretely, an unsafe request with no Origin is admitted if its method is not GET, HEAD or POST, or if it carries exactly one Content-Type that is not application/x-www-form-urlencoded, multipart/form-data or text/plain. Everything else is refused.

The alternatives are NonBrowser.REJECT (refuse every such request) and NonBrowser.REQUIRE_TOKEN (treat it like a browser request and require the token). requireJson(true) additionally requires a single application/json content type on every admitted write.

This relies on browsers sending Origin on every non-simple request they issue. Current browsers do, and the Origin check is the real control for them.

HTML forms are unsupported​

A plain HTML <form> cannot set a request header, so every HTML form POST, including your own app's, is refused with CSRF_TOKEN_MISSING. CsrfBoundary is for applications whose writes go through script that sends the token header (the Juneau views toolkit already does). The token is not accepted as a form field or query parameter on purpose: that would put it in URLs, logs and Referer headers.

Tokens, sessions and cookies​

The default token strategy, CsrfTokens.perSession(), derives one token per session id (an HMAC of the id under a per-process key), so a visitor who loads the page learns only their own token. CsrfTokens.perProcess() uses one token for the whole process; any visitor can read it, so in a multi-user app it adds little beyond the Origin check. In a cluster, give every node the same key so any node accepts any node's token.

Because a per-session token follows the session id, the application must:

  • call request.changeSessionId() on login, or a token seen before login stays valid after it;
  • mark the session cookie HttpOnly and SameSite, and not scope it to a parent Domain;
  • not use URL rewriting for session ids.

The boundary never changes a session id and never sets cookie attributes; those stay the application's.

The value written into a page is masked under a fresh pad on every embed. Masking defends only against BREACH-style compression oracles; anyone who can see the wire value can unmask it. Never log the token header.

Pages that embed a token​

Any code that stamps a token into a response must call RequestBoundaryFilter.markTokenBearing(response) before it writes the first byte of the body. It replaces Cache-Control with private, no-store; without it a shared cache can serve one session's token to another session. This applies to a view host that uses ViewTable.CSRF_ATTR or RegionTable.of(region, csrfToken), and to PageSpec.csrf(...). The console page chrome does it for you.

A token is never embedded into a response that is already committed, because the response can no longer be marked. That includes csrf attributes an author put on the page's body element. The page still loads, and its writes then fail visibly for want of a token.

DNS rebinding and CORS​

A rebound page's Origin and Host agree, so origin checking alone does not stop DNS rebinding. TLS defeats it. A plain-HTTP intranet app should list its host names with allowedHosts(...); the boundary then refuses any request, safe ones included, whose own host is not listed, with 421.

CsrfBoundary does not support cross-origin callers and adds no Access-Control-Allow-* headers. Do not put an origin-reflecting CORS layer in front of it: reflecting every Origin back undoes the check.

REST client: credentials on redirects​

The Apache HttpClient 4.5 transport (ApacheHc45Transport) never sends credentials to a different origin as the result of a redirect. This applies the same way to a client the transport built and to one you supplied with httpClient(...).

When a redirect leads to a different scheme, host or port (including an https to http downgrade), every credential header is removed from that request. Every request is compared with the first origin of the exchange (exact scheme, host and port), not with the previous request. That covers the headers you set on the request (Authorization, Cookie, X-API-Key and the rest of the standard credential set) and the ones HttpClient adds itself: the Cookie from the client's cookie store, and the Authorization from its credentials provider, its preemptive authentication, or the answer to a 401 challenge. A cross-origin server that challenges the redirected request therefore gets no answer, and the caller sees the 401. Proxy-Authorization is not removed, because it is addressed to the proxy and not to the origin. Headers that are not credentials, such as those from setDefaultHeaders(...), are left alone.

A same-origin redirect keeps everything. A chain that returns to the first origin (an SSO round trip, A to B to A, or A to B to C to A) gets that origin's credentials back on the returning request, exactly as an unredirected request to it would: the headers you set, the cookie-store Cookie, and the credentials-provider or preemptive Authorization. Any request that is not at the first origin is stripped, so A to B to A to B strips the second B. The comparison is exact: the same host on a different port or scheme, an IP address instead of the host name, or a different spelling of the name is a different origin and stays stripped. There is no option to turn the stripping off.

Details worth knowing:

  • A hop that returns to the first origin gets the credentials back only when its method is GET or HEAD. If the chain has left the first origin and then comes back with any other method (a 307/308 replay of a POST, say), that hop stays stripped. A 302 that turns a POST into a GET is restored. A request that never left the first origin keeps everything whatever its method.
  • The SSRF-guarded @Remote redirect path is shared by every transport (JDK, OkHttp, Apache HttpClient 4.5 and 5, and the mock transport), so the same first-origin and GET/HEAD rules apply to all of them there.
  • A redirect Location that carries user info (https://user:pw@host/) is refused, because HttpClient would use it to replace the client's stored credentials for that host. The guarded path and a transport-built HC4.5 client enforce this. A client you supplied with httpClient(...) keeps its own redirect strategy, which is not changed.
  • On a 307/308 redirect of a non-GET request, HttpClient 4.5 replays the previous hop's headers rather than the original request's, so the headers you set are not brought back on a return to the first origin, and Host carries over from the previous hop.
  • Proxy-Authorization is not removed, except on a tunnelled route (an https target through a proxy): inside the tunnel the proxy never sees the request, so it is removed like any other credential.
  • A legacy DefaultHttpClient (any AbstractHttpClient) is refused by httpClient(...): it never lets the guard run just before the request is written, so its cookie-store Cookie followed a cross-origin redirect. Build the client with HttpClients.

See also​