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.
| Boundary | Use it when |
|---|---|
LoopbackBoundary | The 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. |
CsrfBoundary | Everything 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, withSec-Fetch-Siteas a fallback, must match the request's own scheme, host and port. A literalnullorigin is refused.- A browser request must also carry a synchronizer token in a request header (
X-Csrf-Tokenby 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.comandhttps://app.example.com:443match). 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
REQUESTdispatches only. AnERRORdispatch of a refused write can arrive without the token header and be refused in turn, replacing the error page with a 403, and aFORWARDto an HTML view looks like a page load and can create a session. The default registration in a servlet container, and Spring Boot'sFilterRegistrationBean, is alreadyREQUESTonly. - 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:
| Stack | Setting |
|---|---|
| Jetty | Add a ForwardedRequestCustomizer to the connector's HttpConfiguration. |
| Tomcat | Add a RemoteIpValve. |
| Spring Boot | server.forward-headers-strategy=native rewrites before any filter runs, so filter order does not matter. |
| Spring Boot | server.forward-headers-strategy=framework registers ForwardedHeaderFilter at Ordered.HIGHEST_PRECEDENCE. Register the boundary's filter strictly later, never at HIGHEST_PRECEDENCE itself. |
| nginx | proxy_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
OriginplusSec-Fetch-Site. - The container's
checkOriginhook runs first. A missing, blank ornullOrigin, or one that is not a well-formedhttporhttpsorigin, is refused with HTTP 403 before the upgrade. - A well-formed
Originthat is cross-site, or does not match the handshake's own origin, is refused after the upgrade: the connection is closed with code4403before any frame is sent and before any bus session is touched. - The handshake has no server name, so the check judges the raw
Hostheader plus the scheme of the handshake's request URI, whateverOriginSourceis set to.wscounts ashttpandwssashttps. - A WebSocket over HTTP/2 (RFC 8441, an extended
CONNECT) has noHost. When a handshake carries neitherHostnorSec-WebSocket-KeyorUpgrade(which mark an RFC 6455 request, and which the container enforces), the authority of its request URI, which is the:authority, stands in forHost. 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
Hostto an internal name, with no container-side host rewriting, same-origin upgrades are refused. That is accepted behavior. Preserve the originalHostat the proxy (in nginx,proxy_set_header Host $host;). If the proxy cannot preserveHost, see Trusting a forwarded origin. - Jetty's
ForwardedRequestCustomizerrewrites theHostthat both ordinary requests and the WebSocket handshake see, usingX-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'sForwardedHeaderFilternever sees the handshake. - On Tomcat the handshake's request URI reports
wsorwss, normalized as above.RemoteIpValverewrites that scheme fromX-Forwarded-Proto, so a TLS-terminating proxy works. It does not rewrite theHostheader the handshake reads, even with the valve'shostHeaderset toX-Forwarded-Host. Unlike Jetty'sForwardedRequestCustomizer, a forwarded host is therefore never trusted for the WebSocket endpoint, and the preserve-Hostadvice 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
trustedProxiesso 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
trustedHopsassumes, and strip any client-supplied copy.trustedProxiesdoes 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-HostplusX-Forwarded-Proto, orForwardedelements carryinghost=andproto=).X-Forwarded-Portis never read: put the port in the host value. A missing, repeated-in-conflict or malformed value is refused with aFORWARDED_*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 asFORWARDED_HOST_MALFORMEDorFORWARDED_PROTO_MALFORMED. -
The entry that counts is the one added by your outermost proxy: the
nth from the right, withnset bytrustedHops. Entries to its left are client-controlled and ignored. SettrustedHopsto 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.
allowedHostsis checked against the forwarded host. -
If the container also rewrites forwarded headers, the two must agree: under
OriginSource.CONTAINERa disagreement is refused withFORWARDED_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 noallowedHostsis 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 ownHost, as if forwarding were off, soallowedHostsstill applies. List the addresses your proxies connect from, not your whole network. Host bits below the prefix are ignored (10.0.12.7/24means10.0.12.0/24), and a/0entry (0.0.0.0/0or::/0) trusts every peer of that address family. Only the last proxy before the server is checked.trustedHopsstill decides which entry counts, and an allow-list does not make an overstatedtrustedHopssafe. RequiresOriginSource.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 takesX-Forwarded-Forfrom anyone), Tomcat'sRemoteIpValveand Spring Boot'sforward-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
trustedProxiesset a handshake that carries a forwarded host is always refused, and one without it is judged on its ownHostand scheme. Bus sockets then work on Tomcat only if TLS ends at Tomcat (or nothing uses TLS), the proxy preservesHost, and it sends noX-Forwarded-Hoston the socket route. Otherwise leavetrustedProxiesunset on Tomcat.Direct callers. With
allowedHostsset, a caller that bypasses the proxy is checked on its ownHost, so add the backend name it uses (backend) toallowedHostsif it must write. That is safe: a rebound page'sHostis 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
HttpOnlyandSameSite, and not scope it to a parentDomain; - 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
GETorHEAD. If the chain has left the first origin and then comes back with any other method (a307/308replay of aPOST, say), that hop stays stripped. A302that turns aPOSTinto aGETis restored. A request that never left the first origin keeps everything whatever its method. - The SSRF-guarded
@Remoteredirect path is shared by every transport (JDK, OkHttp, Apache HttpClient 4.5 and 5, and the mock transport), so the same first-origin andGET/HEADrules apply to all of them there. - A redirect
Locationthat 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 withhttpClient(...)keeps its own redirect strategy, which is not changed. - On a
307/308redirect of a non-GETrequest, 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, andHostcarries over from the previous hop. Proxy-Authorizationis not removed, except on a tunnelled route (anhttpstarget through a proxy): inside the tunnel the proxy never sees the request, so it is removed like any other credential.- A legacy
DefaultHttpClient(anyAbstractHttpClient) is refused byhttpClient(...): it never lets the guard run just before the request is written, so its cookie-storeCookiefollowed a cross-origin redirect. Build the client withHttpClients.
See also
- Content Security Policy — locking down Juneau's auto-generated HTML pages.
- Marshalling Security — parser-level security characteristics.
- Views Security Model — how the views toolkit uses the CSRF boundary.