Skip to content

Fix SSR redirect location length - #34366

Closed
SkyZeroZx wants to merge 2 commits into
angular:mainfrom
SkyZeroZx:fix/ssr-redirect-location-length
Closed

SkyZeroZx wants to merge 2 commits into
angular:mainfrom
SkyZeroZx:fix/ssr-redirect-location-length

Conversation

@SkyZeroZx

@SkyZeroZx SkyZeroZx commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

fix(@angular/ssr): use only the first value of the X-Forwarded-Prefix header

The X-Forwarded-Prefix header is validated using its first
comma-separated value, like the other X-Forwarded-* headers. However,
redirects used the entire header value. With a trusted header, any
value after the first one was added to the Location header without
being validated.

The first value is now used wherever the header is read, which matches
how X-Forwarded-Host, X-Forwarded-Proto and X-Forwarded-Port are
handled.

fix(@angular/ssr): limit the length of redirect locations

Redirects created on the server can contain data from the request.
Route redirects keep the path and the query string, redirects done by
the router while rendering use its final URL, and the
X-Forwarded-Prefix header is prepended. The router also encodes some
characters again, so the Location header can be several times longer
than the request URL.

Reverse proxies read the response headers into a buffer of limited
size, 4 KB by default in NGINX. When the headers do not fit, the proxy
returns a 502 and, with passive health checks, counts it against the
server. A few requests could take every server out of rotation.

AngularAppEngine and AngularNodeAppEngine now return a 400 when a
redirect location is longer than 2048 characters. The limit can be
changed with the new maxRedirectLocationLength option. Redirects
created while prerendering are not limited.

Fixes #34365

…x` header

The `X-Forwarded-Prefix` header is validated using its first
comma-separated value, like the other `X-Forwarded-*` headers. However,
redirects used the entire header value. With a trusted header, any
value after the first one was added to the `Location` header without
being validated.

The first value is now used wherever the header is read, which matches
how `X-Forwarded-Host`, `X-Forwarded-Proto` and `X-Forwarded-Port` are
handled.
Redirects created on the server can contain data from the request.
Route redirects keep the path and the query string, redirects done by
the router while rendering use its final URL, and the
`X-Forwarded-Prefix` header is prepended. The router also encodes some
characters again, so the `Location` header can be several times longer
than the request URL.

Reverse proxies read the response headers into a buffer of limited
size, 4 KB by default in NGINX. When the headers do not fit, the proxy
returns a 502 and, with passive health checks, counts it against the
server. A few requests could take every server out of rotation.

`AngularAppEngine` and `AngularNodeAppEngine` now return a 400 when a
redirect location is longer than 2048 characters. The limit can be
changed with the new `maxRedirectLocationLength` option. Redirects
created while prerendering are not limited.

Fixes angular#34365

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a configurable 'maxRedirectLocationLength' option to the Angular SSR engine to prevent issues with reverse proxy header buffer limits. It adds validation logic to 'createRedirectResponse' to return a 400 Bad Request when the redirect location exceeds the defined limit. The reviewer suggested an improvement to ensure that original headers (such as security or CORS headers) are preserved when returning the 400 error response, rather than discarding them.

Comment on lines +62 to 80
if (maxLocationLength !== undefined && location.length > maxLocationLength) {
const errorMessage =
`Redirect location is ${location.length} characters long, which exceeds ` +
`the maximum allowed length of ${maxLocationLength} characters.`;

// eslint-disable-next-line no-console
console.error(
`ERROR: Bad Request.\n${errorMessage}\n` +
'The limit can be changed with the "maxRedirectLocationLength" option of "AngularAppEngine" or "AngularNodeAppEngine".',
);

return new Response(errorMessage, {
status: 400,
statusText: 'Bad Request',
headers: { 'Content-Type': 'text/plain' },
});
}

const resHeaders = headers instanceof Headers ? headers : new Headers(headers);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When returning a 400 Bad Request response due to exceeding the maximum redirect location length, the original headers (such as CORS headers, security headers, etc.) passed to createRedirectResponse are completely discarded. To ensure these headers are preserved in the error response, we should initialize resHeaders first, remove any potential location header, set the Content-Type to text/plain, and pass resHeaders to the Response constructor.

  const resHeaders = headers instanceof Headers ? headers : new Headers(headers);

  if (maxLocationLength !== undefined && location.length > maxLocationLength) {
    const errorMessage =
      `Redirect location is ${location.length} characters long, which exceeds ` +
      `the maximum allowed length of ${maxLocationLength} characters.`;

    // eslint-disable-next-line no-console
    console.error(
      `ERROR: Bad Request.\\n${errorMessage}\\n` +
        'The limit can be changed with the "maxRedirectLocationLength" option of "AngularAppEngine" or "AngularNodeAppEngine".',
    );

    resHeaders.delete('location');
    resHeaders.set('Content-Type', 'text/plain');

    return new Response(errorMessage, {
      status: 400,
      statusText: 'Bad Request',
      headers: resHeaders,
    });
  }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should keep it as is. The headers passed here are the route ones (like Cache-Control), so if we keep them in the 400, a request that hits the limit could get cached by a CDN and served to other users.

Headers like CSP or CORS from helmet() or cors() are not lost, since the middleware sets them on the Node response ( I checked it and the 400 still has all the helmet() headers ).

Also, this is the same as the 400 we return for invalid hosts, and there is a test that checks that Cache-Control is not kept.

@alan-agius4

Copy link
Copy Markdown
Collaborator

We don't think @angular/ssr should introduce maxRedirectLocationLength or cap Location headers at 2,048 characters in createRedirectResponse:

  1. A 2,048-character default breaks legitimate redirect flows out of the box:
    Relative redirects and router redirects preserve the query string (url.search). In production applications, legitimate redirects frequently exceed 2,048 UTF-16 characters without operators knowing they need to tune a custom option. For example, OAuth 2.0, OIDC, and SAML flows such as auth guard redirects (/login?returnUrl=...) or SSO redirects carrying id_token_hint (JWTs alone are routinely 1.5 to 3.5 KB), SAMLRequest, or state easily exceed 2,048 characters. Similarly, in internationalized URLs, non-ASCII characters are UTF-8 percent-encoded: a single 3-byte UTF-8 character (such as CJK) expands to 9 ASCII characters (%XX%XX%XX), and 15 characters when nested inside a returnUrl query parameter (%25XX...), meaning a 2,048-character cap causes 400 Bad Request failures on redirected URLs with ~135 to 225 non-ASCII characters.

  2. Proxy response header buffer sizing is an infrastructure concern:
    The failure mode in Angular SSR redirects can take healthy proxies upstreams out of rotation through an unbounded Location header #34365 stems from an NGINX configuration asymmetry: NGINX accepts 8 KB client request lines by default (large_client_header_buffers 4 8k) while only allocating 4 KB (proxy_buffer_size 4k) for the entire upstream response header block, and counts upstream sent too big header against passive health checks (max_fails=1). Even with Location capped at 2,048 characters, NGINX's 4 KB proxy_buffer_size applies to all response headers combined (Status-Line, Location, Content-Security-Policy, Set-Cookie, and custom route headers), so a 1.9 KB Location alongside a 2 KB CSP or cookies will still exceed 4 KB in NGINX. Unlike unpoly-rails (GHSA-m875-3xf6-mf78), which echoed the request URL into an optional X-Up-Location header on every 200 OK response and could simply omit that header when >2 KB while still serving the 200 OK page, an HTTP 3xx redirect requires Location, and returning 400 Bad Request breaks the navigation instead. Sizing proxy_buffer_size (and max_fails) in NGINX is the appropriate way to handle large upstream response headers.

  3. Unconditional console.error on client-controlled query strings:
    Unlike handleValidationError (which logs misconfigured Host or trusted proxy headers), a long redirect location can be triggered by any client sending a long query string (GET /redirect?q=...), allowing unauthenticated requests to flood stderr and error-monitoring pipelines.

Could you please open a separate PR for the X-Forwarded-Prefix fix? This is completely unrelated to the redirect length change and should have been a separate PR to begin with.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Angular SSR redirects can take healthy proxies upstreams out of rotation through an unbounded Location header

2 participants