From 8ec01970d26a154fa14258a172535f2b4a0851ed Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Fri, 29 May 2026 11:23:29 -0500 Subject: [PATCH 1/3] fix(http): skip transfer cache for fetch credentialed requests Treat HttpClient requests using `credentials: 'include'` and `same-origin` as credentialed when deciding whether a response can be stored in the HTTP transfer cache. The transfer cache already skips requests with `withCredentials`, `Cookie`, `Authorization`, or `Proxy-Authorization` because those responses may contain user-specific data. Fetch-backed requests can express the same credentialed behavior through the `credentials` option, so these responses must not be serialized into the SSR HTML. This keeps credentialed SSR responses out of TransferState and aligns the cache eligibility check with the fetch request options supported by HttpClient. --- adev/src/content/guide/ssr.md | 4 +- packages/common/http/src/transfer_cache.ts | 9 +++- .../common/http/test/transfer_cache_spec.ts | 41 +++++++++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/adev/src/content/guide/ssr.md b/adev/src/content/guide/ssr.md index a0cc7686cdef..70daba551511 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`. You can override those settings by using `withHttpTransferCacheOptions` to 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. You can override those settings by using `withHttpTransferCacheOptions` to the hydration configuration. ```ts import {bootstrapApplication} from '@angular/platform-browser'; @@ -487,7 +487,7 @@ Use this only when `POST` requests are **idempotent** and safe to reuse between ### `includeRequestsWithAuthHeaders` Determines whether requests containing `Authorization`, `Proxy‑Authorization`, or `Cookie` headers are eligible for caching. -By default, these are excluded to prevent caching user‑specific responses. Requests sent with `withCredentials` are also excluded by default. +By default, these are excluded to prevent caching user‑specific responses. Requests sent with `withCredentials` or Fetch API `credentials` set to `include` or `same-origin` are also excluded by default. ```ts withHttpTransferCacheOptions({ diff --git a/packages/common/http/src/transfer_cache.ts b/packages/common/http/src/transfer_cache.ts index 3993e61bc833..7a30b629ba9c 100644 --- a/packages/common/http/src/transfer_cache.ts +++ b/packages/common/http/src/transfer_cache.ts @@ -42,7 +42,8 @@ import {HttpParams} from './params'; * (for example using GraphQL). * @param includeRequestsWithAuthHeaders Enables caching of requests containing `Authorization`, * `Proxy-Authorization`, or `Cookie` headers. By default, these requests are excluded from - * caching. Requests sent using `withCredentials` are also excluded by default. + * caching. Requests sent using `withCredentials` or Fetch API `credentials` modes that can send + * credentials are also excluded by default. * * @see [Configuring the caching options](guide/ssr#configuring-the-caching-options) * @@ -132,7 +133,7 @@ function shouldCacheRequest(req: HttpRequest, options: CacheOptions): b !isCacheActive || requestOptions === false || // Do not cache requests sent with credentials. - req.withCredentials || + hasOutgoingCredentials(req) || // POST requests are allowed either globally or at request level (requestMethod === 'POST' && !globalOptions.includePostRequests && !requestOptions) || (requestMethod !== 'POST' && !ALLOWED_METHODS.includes(requestMethod)) || @@ -300,6 +301,10 @@ function hasAuthHeaders(req: HttpRequest): boolean { ); } +function hasOutgoingCredentials(req: HttpRequest): boolean { + return req.withCredentials || req.credentials === 'include' || req.credentials === 'same-origin'; +} + function getFilteredHeaders( headers: HttpHeaders, includeHeaders: string[] | undefined, diff --git a/packages/common/http/test/transfer_cache_spec.ts b/packages/common/http/test/transfer_cache_spec.ts index b9d0015b3ab3..e67e65b98394 100644 --- a/packages/common/http/test/transfer_cache_spec.ts +++ b/packages/common/http/test/transfer_cache_spec.ts @@ -41,6 +41,7 @@ interface RequestParams { transferCache?: {includeHeaders: string[]} | boolean; headers?: {[key: string]: string}; withCredentials?: boolean; + credentials?: RequestCredentials; body?: RequestBody; } @@ -440,6 +441,36 @@ describe('TransferCache', () => { }); }); + it('should not cache requests with included credentials', async () => { + makeRequestAndExpectOne('/test-auth', 'foo', { + credentials: 'include', + }); + + makeRequestAndExpectOne('/test-auth', 'foo', { + credentials: 'include', + }); + }); + + it('should not cache requests with same-origin credentials', async () => { + makeRequestAndExpectOne('/test-auth', 'foo', { + credentials: 'same-origin', + }); + + makeRequestAndExpectOne('/test-auth', 'foo', { + credentials: 'same-origin', + }); + }); + + it('should cache requests with omitted credentials', async () => { + makeRequestAndExpectOne('/test-auth', 'foo', { + credentials: 'omit', + }); + + makeRequestAndExpectNone('/test-auth', 'GET', { + credentials: 'omit', + }); + }); + it('should cache POST with the differing body in string form', () => { makeRequestAndExpectOne('/test-1', null, {method: 'POST', transferCache: true, body: 'foo'}); makeRequestAndExpectNone('/test-1', 'POST', {transferCache: true, body: 'foo'}); @@ -602,6 +633,16 @@ describe('TransferCache', () => { }); }); + it(`should not cache requests with included credentials when 'includeRequestsWithAuthHeaders' is 'true'`, async () => { + makeRequestAndExpectOne('/test-auth', 'foo', { + credentials: 'include', + }); + + makeRequestAndExpectOne('/test-auth', 'foo', { + credentials: 'include', + }); + }); + it('should cache a POST request', () => { makeRequestAndExpectOne('/include?foo=1', 'post-body', {method: 'POST'}); From 4d150156cac7d6e2c3415f16632597c575a838ea Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Sat, 30 May 2026 17:30:10 -0500 Subject: [PATCH 2/3] fix(common): skip transfer cache for uncacheable HTTP traffic Do not store HTTP transfer cache entries when either the request or response uses `Cache-Control: no-store`, `Cache-Control: private`, or `Cache-Control: no-cache`. Also skip transfer cache when requests use the Fetch API `cache` option with `no-store` or `no-cache`. Because transfer cache serializes SSR HTTP responses into the rendered HTML, Angular now treats these directives conservatively to avoid exposing sensitive or explicitly uncacheable data through `TransferState`. --- adev/src/content/guide/ssr.md | 6 +- packages/common/http/src/transfer_cache.ts | 29 ++- .../common/http/test/transfer_cache_spec.ts | 215 +++++++++++++++++- 3 files changed, 246 insertions(+), 4 deletions(-) diff --git a/adev/src/content/guide/ssr.md b/adev/src/content/guide/ssr.md index 70daba551511..338b9237fc56 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. You can override those settings by using `withHttpTransferCacheOptions` to 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`. You can override the request filtering settings by using `withHttpTransferCacheOptions` in the hydration configuration. ```ts import {bootstrapApplication} from '@angular/platform-browser'; @@ -467,6 +467,8 @@ withHttpTransferCacheOptions({ IMPORTANT: Avoid including sensitive headers like authentication tokens. These can leak user‑specific data between requests. +Including `Cache-Control` in `includeHeaders` only makes that header available on the hydrated response. Angular already evaluates `Cache-Control` headers automatically when deciding whether a request or response is eligible for transfer cache. + --- ### `includePostRequests` @@ -558,6 +560,8 @@ 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. + 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. ## Configuring a server diff --git a/packages/common/http/src/transfer_cache.ts b/packages/common/http/src/transfer_cache.ts index 7a30b629ba9c..87d61472cc12 100644 --- a/packages/common/http/src/transfer_cache.ts +++ b/packages/common/http/src/transfer_cache.ts @@ -139,6 +139,10 @@ function shouldCacheRequest(req: HttpRequest, options: CacheOptions): b (requestMethod !== 'POST' && !ALLOWED_METHODS.includes(requestMethod)) || // Do not cache requests with authentication or cookie headers unless explicitly enabled. (!globalOptions.includeRequestsWithAuthHeaders && hasAuthHeaders(req)) || + // Do not cache requests that explicitly forbid caching via Cache-Control + // or Fetch API cache mode. + hasUncacheableCacheControl(req.headers) || + isNonCacheableRequest(req.cache) || globalOptions.filter?.(req) === false ) { return false; @@ -271,8 +275,9 @@ export function transferCacheInterceptorFn( // Request not found in cache. Make the request and cache it if on the server. return event$.pipe( tap((event: HttpEvent) => { - // Only cache successful HTTP responses. - if (event instanceof HttpResponse) { + // Only cache successful HTTP responses that do not have Cache-Control + // directives that forbid shared caching (no-store or private). + if (event instanceof HttpResponse && !hasUncacheableCacheControl(event.headers)) { transferState.set(storeKey, { [BODY]: req.responseType === 'arraybuffer' || req.responseType === 'blob' @@ -301,6 +306,26 @@ function hasAuthHeaders(req: HttpRequest): boolean { ); } +const UNCACHEABLE_CACHE_CONTROL_DIRECTIVES = new Set(['no-store', 'private', 'no-cache']); + +function hasUncacheableCacheControl(headers: HttpHeaders): boolean { + const cacheControl = headers.get('cache-control'); + + if (!cacheControl) { + return false; + } + + return cacheControl.split(',').some((directive) => { + const directiveName = directive.trim().split('=', 1)[0].trim().toLowerCase(); + + return UNCACHEABLE_CACHE_CONTROL_DIRECTIVES.has(directiveName); + }); +} + +function isNonCacheableRequest(cache: RequestCache): boolean { + return cache === 'no-cache' || cache === 'no-store'; +} + function hasOutgoingCredentials(req: HttpRequest): boolean { return req.withCredentials || req.credentials === 'include' || req.credentials === 'same-origin'; } diff --git a/packages/common/http/test/transfer_cache_spec.ts b/packages/common/http/test/transfer_cache_spec.ts index e67e65b98394..cd51ad97e352 100644 --- a/packages/common/http/test/transfer_cache_spec.ts +++ b/packages/common/http/test/transfer_cache_spec.ts @@ -40,8 +40,11 @@ interface RequestParams { observe?: 'body' | 'response'; transferCache?: {includeHeaders: string[]} | boolean; headers?: {[key: string]: string}; + /** Separate response headers for flush(); falls back to headers if not set */ + responseHeaders?: {[key: string]: string}; withCredentials?: boolean; credentials?: RequestCredentials; + cache?: RequestCache; body?: RequestBody; } @@ -158,6 +161,104 @@ describe('TransferCache', () => { expect(firstNext).toHaveBeenCalledTimes(1); expect(secondNext).not.toHaveBeenCalled(); }); + + it('should not cache responses with Cache-Control: no-store', () => { + configureInterceptor(); + + const request = new HttpRequest('GET', '/test-no-store'); + + const firstNext = jasmine.createSpy('firstNext').and.returnValue( + of( + new HttpResponse({ + body: 'sensitive-data', + headers: new HttpHeaders({'Cache-Control': 'no-store'}), + }), + ), + ); + const secondNext = jasmine + .createSpy('secondNext') + .and.returnValue(of(new HttpResponse({body: 'fresh-data'}))); + + runOnServer(() => { + expect(runInterceptor(request, firstNext).body).toBe('sensitive-data'); + expect(runInterceptor(request, secondNext).body).toBe('fresh-data'); + }); + + expect(firstNext).toHaveBeenCalledTimes(1); + expect(secondNext).toHaveBeenCalledTimes(1); + }); + + it('should not cache responses with Cache-Control: private', () => { + configureInterceptor(); + + const request = new HttpRequest('GET', '/test-private'); + + const firstNext = jasmine.createSpy('firstNext').and.returnValue( + of( + new HttpResponse({ + body: 'user-data', + headers: new HttpHeaders({'Cache-Control': 'private'}), + }), + ), + ); + const secondNext = jasmine + .createSpy('secondNext') + .and.returnValue(of(new HttpResponse({body: 'public-data'}))); + + runOnServer(() => { + expect(runInterceptor(request, firstNext).body).toBe('user-data'); + expect(runInterceptor(request, secondNext).body).toBe('public-data'); + }); + + expect(firstNext).toHaveBeenCalledTimes(1); + expect(secondNext).toHaveBeenCalledTimes(1); + }); + + it('should not cache requests with Cache-Control: no-store', () => { + configureInterceptor(); + + const request = new HttpRequest('GET', '/test-req-no-store', null, { + headers: new HttpHeaders({'Cache-Control': 'no-store'}), + }); + + const firstNext = jasmine + .createSpy('firstNext') + .and.returnValue(of(new HttpResponse({body: 'data'}))); + const secondNext = jasmine + .createSpy('secondNext') + .and.returnValue(of(new HttpResponse({body: 'fresh-data'}))); + + runOnServer(() => { + expect(runInterceptor(request, firstNext).body).toBe('data'); + expect(runInterceptor(request, secondNext).body).toBe('fresh-data'); + }); + + expect(firstNext).toHaveBeenCalledTimes(1); + expect(secondNext).toHaveBeenCalledTimes(1); + }); + + it('should not cache requests with Cache-Control: no-cache', () => { + configureInterceptor(); + + const request = new HttpRequest('GET', '/test-req-no-cache', null, { + headers: new HttpHeaders({'Cache-Control': 'no-cache'}), + }); + + const firstNext = jasmine + .createSpy('firstNext') + .and.returnValue(of(new HttpResponse({body: 'data'}))); + const secondNext = jasmine + .createSpy('secondNext') + .and.returnValue(of(new HttpResponse({body: 'fresh-data'}))); + + runOnServer(() => { + expect(runInterceptor(request, firstNext).body).toBe('data'); + expect(runInterceptor(request, secondNext).body).toBe('fresh-data'); + }); + + expect(firstNext).toHaveBeenCalledTimes(1); + expect(secondNext).toHaveBeenCalledTimes(1); + }); }); describe('withHttpTransferCache', () => { @@ -178,7 +279,9 @@ describe('TransferCache', () => { TestBed.inject(HttpClient) .request(params?.method ?? 'GET', url, params) .subscribe((r) => (response = r)); - TestBed.inject(HttpTestingController).expectOne(url).flush(body, {headers: params?.headers}); + TestBed.inject(HttpTestingController) + .expectOne(url) + .flush(body, {headers: params?.responseHeaders ?? params?.headers}); return response; } @@ -471,6 +574,116 @@ describe('TransferCache', () => { }); }); + it('should not cache responses with Cache-Control: no-store', () => { + makeRequestAndExpectOne('/test-no-store', 'private-data', { + responseHeaders: {'Cache-Control': 'no-store'}, + }); + + makeRequestAndExpectOne('/test-no-store', 'fresh-data'); + }); + + it('should not cache responses with Cache-Control: private', () => { + makeRequestAndExpectOne('/test-private', 'user-data', { + responseHeaders: {'Cache-Control': 'private'}, + }); + + makeRequestAndExpectOne('/test-private', 'fresh-data'); + }); + + 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'}, + }); + + makeRequestAndExpectOne('/test-multi', 'fresh-data'); + }); + + it('should not cache responses with Cache-Control containing private among other directives', () => { + makeRequestAndExpectOne('/test-multi-private', 'data', { + responseHeaders: {'Cache-Control': 'max-age=60, private'}, + }); + + makeRequestAndExpectOne('/test-multi-private', 'fresh-data'); + }); + + it('should cache responses with Cache-Control: public', () => { + makeRequestAndExpectOne('/test-public', 'public-data', { + responseHeaders: {'Cache-Control': 'public'}, + }); + + makeRequestAndExpectNone('/test-public'); + }); + + it('should cache responses with Cache-Control: max-age without no-store or private', () => { + makeRequestAndExpectOne('/test-max-age', 'cacheable-data', { + responseHeaders: {'Cache-Control': 'max-age=3600'}, + }); + + makeRequestAndExpectNone('/test-max-age'); + }); + + it('should cache responses without Cache-Control header', () => { + makeRequestAndExpectOne('/test-no-cc', 'data'); + + makeRequestAndExpectNone('/test-no-cc'); + }); + + it('should not cache responses with Cache-Control: no-store (case-insensitive)', () => { + makeRequestAndExpectOne('/test-case-resp', 'data', { + responseHeaders: {'Cache-Control': 'No-Store'}, + }); + + makeRequestAndExpectOne('/test-case-resp', 'fresh-data'); + }); + + it('should not cache requests with Cache-Control: no-store', () => { + makeRequestAndExpectOne('/test-req-no-store', 'data', { + headers: {'Cache-Control': 'no-store'}, + }); + + makeRequestAndExpectOne('/test-req-no-store', 'fresh-data'); + }); + + it('should not cache requests with Cache-Control: no-cache', () => { + makeRequestAndExpectOne('/test-req-no-cache', 'data', { + headers: {'Cache-Control': 'no-cache'}, + }); + + makeRequestAndExpectOne('/test-req-no-cache', 'fresh-data'); + }); + + it('should not cache requests with Cache-Control containing no-store among other directives', () => { + makeRequestAndExpectOne('/test-req-multi', 'data', { + headers: {'Cache-Control': 'max-age=0, no-store'}, + }); + + makeRequestAndExpectOne('/test-req-multi', 'fresh-data'); + }); + + it('should cache requests with Cache-Control: max-age', () => { + makeRequestAndExpectOne('/test-req-max-age', 'data', { + headers: {'Cache-Control': 'max-age=3600'}, + }); + + makeRequestAndExpectNone('/test-req-max-age'); + }); + + it('should not cache requests with Fetch API cache mode: no-store', () => { + makeRequestAndExpectOne('/test-fetch-no-store', 'data', { + cache: 'no-store', + }); + + makeRequestAndExpectOne('/test-fetch-no-store', 'fresh-data'); + }); + + it('should not cache requests with Fetch API cache mode: no-cache', () => { + makeRequestAndExpectOne('/test-fetch-no-cache', 'data', { + cache: 'no-cache', + }); + + makeRequestAndExpectOne('/test-fetch-no-cache', 'fresh-data'); + }); + it('should cache POST with the differing body in string form', () => { makeRequestAndExpectOne('/test-1', null, {method: 'POST', transferCache: true, body: 'foo'}); makeRequestAndExpectNone('/test-1', 'POST', {transferCache: true, body: 'foo'}); From 64ce11fcd4b227fbfab08a0509f858535928732f Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Mon, 1 Jun 2026 10:13:16 -0500 Subject: [PATCH 3/3] fixup! fix(common): skip transfer cache for uncacheable HTTP traffic --- packages/common/http/src/transfer_cache.ts | 2 +- .../core/test/bundling/hydration/bundle.golden_symbols.json | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/common/http/src/transfer_cache.ts b/packages/common/http/src/transfer_cache.ts index 87d61472cc12..1871eada37ec 100644 --- a/packages/common/http/src/transfer_cache.ts +++ b/packages/common/http/src/transfer_cache.ts @@ -316,7 +316,7 @@ function hasUncacheableCacheControl(headers: HttpHeaders): boolean { } return cacheControl.split(',').some((directive) => { - const directiveName = directive.trim().split('=', 1)[0].trim().toLowerCase(); + const directiveName = directive.split('=', 1)[0].trim().toLowerCase(); return UNCACHEABLE_CACHE_CONTROL_DIRECTIVES.has(directiveName); }); diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index 1f369eabc89f..29316d94a5d7 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -283,6 +283,7 @@ "TracingAction", "TracingService", "TransferState", + "UNCACHEABLE_CACHE_CONTROL_DIRECTIVES", "USE_VALUE", "UnsubscriptionError", "VIEW_REFS", @@ -647,9 +648,11 @@ "hasLift", "hasMatchingDehydratedView", "hasOnDestroy", + "hasOutgoingCredentials", "hasParentInjector", "hasSkipHydrationAttrOnRElement", "hasSkipHydrationAttrOnTNode", + "hasUncacheableCacheControl", "hydrateAndInvokeBlockListeners", "icuContainerIterate", "identity", @@ -721,6 +724,7 @@ "isMiddleClick", "isModifiedClickEvent", "isMouseSpecialEvent", + "isNonCacheableRequest", "isNotFound", "isObserver", "isPositive",