From 3728f9c5f56d79e4edf57dea00317c8316699056 Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:45:33 -0500 Subject: [PATCH] fix(http): prevent caching of responses with Set-Cookie headers Skip HttpTransferCache serialization for HTTP responses that contain a Set-Cookie header. Cookie-setting responses commonly represent session-specific, user-specific, or security-sensitive state. Serializing their bodies into SSR TransferState can embed sensitive data into the generated HTML, where it may be reused during hydration or replayed by a shared cache/CDN. (cherry picked from commit 80795defc600a9ef2ed64aa5c9d5fa6d919401fc) --- adev/src/content/guide/ssr.md | 4 +-- packages/common/http/src/transfer_cache.ts | 10 ++++-- .../common/http/test/transfer_cache_spec.ts | 34 +++++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/adev/src/content/guide/ssr.md b/adev/src/content/guide/ssr.md index a9122cff9c6f..3302e17aceed 100644 --- a/adev/src/content/guide/ssr.md +++ b/adev/src/content/guide/ssr.md @@ -432,7 +432,7 @@ To configure this, update your `angular.json` file as follows: You can customize how Angular caches HTTP responses during server‑side rendering (SSR) and reuses them during hydration by configuring `HttpTransferCacheOptions`. This configuration is provided globally using `withHttpTransferCacheOptions` inside `provideClientHydration()`. -By default, `HttpClient` caches all `HEAD` and `GET` requests which don't contain `Authorization`, `Proxy-Authorization`, or `Cookie` headers and are not sent with `withCredentials` or Fetch API `credentials` modes that can send credentials. Angular also skips transfer cache when a request or response includes `Cache-Control` directives that forbid caching (`no-store`, `no-cache`, or `private`), or when the Fetch API `cache` option is set to `no-store` or `no-cache`. You can override the request filtering settings by using `withHttpTransferCacheOptions` in the hydration configuration. +By default, `HttpClient` caches all `HEAD` and `GET` requests which don't contain `Authorization`, `Proxy-Authorization`, or `Cookie` headers and are not sent with `withCredentials` or Fetch API `credentials` modes that can send credentials. Angular also skips transfer cache when a request or response includes `Cache-Control` directives that forbid caching (`no-store`, `no-cache`, or `private`), or when the Fetch API `cache` option is set to `no-store` or `no-cache`. Responses that carry a `Set-Cookie` header are also skipped. You can override the request filtering settings by using `withHttpTransferCacheOptions` in the hydration configuration. ```ts import {bootstrapApplication} from '@angular/platform-browser'; @@ -560,7 +560,7 @@ To disable caching for an individual request, you can specify the [`transferCach httpClient.get('/api/sensitive-data', {transferCache: false}); ``` -`HttpTransferCache` does not cache requests or responses that explicitly opt out of caching. Angular skips transfer cache entries when a request includes a `Cache-Control` header with `no-store`, `no-cache`, or `private`, or when the request uses the Fetch API `cache` option set to `no-store` or `no-cache`. Responses with `Cache-Control: no-store`, `Cache-Control: no-cache`, or `Cache-Control: private` are also not stored in the transfer cache. +`HttpTransferCache` does not cache requests or responses that explicitly opt out of caching. Angular skips transfer cache entries when a request includes a `Cache-Control` header with `no-store`, `no-cache`, or `private`, or when the request uses the Fetch API `cache` option set to `no-store` or `no-cache`. Responses with `Cache-Control: no-store`, `Cache-Control: no-cache`, or `Cache-Control: private` are also not stored in the transfer cache. Responses that include a `Set-Cookie` header are likewise not stored, as they typically carry user-specific state. NOTE: If your application uses different HTTP origins to make API calls on the server and on the client, the `HTTP_TRANSFER_CACHE_ORIGIN_MAP` token allows you to establish a mapping between those origins, so that `HttpTransferCache` feature can recognize those requests as the same ones and reuse the data cached on the server during hydration on the client. diff --git a/packages/common/http/src/transfer_cache.ts b/packages/common/http/src/transfer_cache.ts index e2c7ae05162a..f1dc1f169fe1 100644 --- a/packages/common/http/src/transfer_cache.ts +++ b/packages/common/http/src/transfer_cache.ts @@ -288,8 +288,10 @@ export function transferCacheInterceptorFn( const {headers, body, status, statusText} = event; // Only cache successful HTTP responses that do not have Cache-Control - // directives that forbid shared caching (no-store or private). - if (hasUncacheableCacheControl(headers)) { + // directives that forbid shared caching (no-store or private) and do not + // carry a Set-Cookie header. A Set-Cookie header marks the response as + // user-specific. + if (hasUncacheableCacheControl(headers) || hasSetCookieHeader(headers)) { return; } @@ -344,6 +346,10 @@ function hasUncacheableCacheControl(headers: HttpHeaders): boolean { }); } +function hasSetCookieHeader(headers: HttpHeaders): boolean { + return headers.has('set-cookie'); +} + function isNonCacheableRequest(cache: RequestCache): boolean { return cache === 'no-cache' || cache === 'no-store'; } diff --git a/packages/common/http/test/transfer_cache_spec.ts b/packages/common/http/test/transfer_cache_spec.ts index 79cd88413ca6..439e7c85051f 100644 --- a/packages/common/http/test/transfer_cache_spec.ts +++ b/packages/common/http/test/transfer_cache_spec.ts @@ -215,6 +215,32 @@ describe('TransferCache', () => { expect(secondNext).toHaveBeenCalledTimes(1); }); + it('should not cache responses with a Set-Cookie header', () => { + configureInterceptor(); + + const request = new HttpRequest('GET', '/test-set-cookie'); + + const firstNext = jasmine.createSpy('firstNext').and.returnValue( + of( + new HttpResponse({ + body: 'user-a-session', + headers: new HttpHeaders({'Set-Cookie': 'session=user-a; HttpOnly'}), + }), + ), + ); + const secondNext = jasmine + .createSpy('secondNext') + .and.returnValue(of(new HttpResponse({body: 'user-b-session'}))); + + runOnServer(() => { + expect(runInterceptor(request, firstNext).body).toBe('user-a-session'); + expect(runInterceptor(request, secondNext).body).toBe('user-b-session'); + }); + + expect(firstNext).toHaveBeenCalledTimes(1); + expect(secondNext).toHaveBeenCalledTimes(1); + }); + it('should not cache requests with Cache-Control: no-store', () => { configureInterceptor(); @@ -591,6 +617,14 @@ describe('TransferCache', () => { makeRequestAndExpectOne('/test-private', 'fresh-data'); }); + it('should not cache responses with a Set-Cookie header', () => { + makeRequestAndExpectOne('/test-set-cookie', 'user-a-session', { + responseHeaders: {'Set-Cookie': 'session=user-a; HttpOnly'}, + }); + + makeRequestAndExpectOne('/test-set-cookie', 'user-b-session'); + }); + it('should not cache responses with Cache-Control containing no-store among other directives', () => { makeRequestAndExpectOne('/test-multi', 'data', { responseHeaders: {'Cache-Control': 'max-age=0, no-store, must-revalidate'},