From 7dcf28f3b77d47b04df5fbae78390f60a44a59ab Mon Sep 17 00:00:00 2001 From: Yenya030 Date: Tue, 31 Mar 2026 15:25:43 -0500 Subject: [PATCH 1/2] fix(http): skip TransferCache for cookie-bearing requests by default Treat requests with a Cookie header like other auth-bearing requests and skip TransferCache caching them by default. This preserves the explicit opt-in path via includeRequestsWithAuthHeaders, adds regression coverage for cookie-bearing requests, and updates the SSR guide to document the behavior. --- adev/src/content/guide/ssr.md | 4 +- packages/common/http/src/transfer_cache.ts | 17 +-- .../common/http/test/transfer_cache_spec.ts | 102 +++++++++++++++++- 3 files changed, 113 insertions(+), 10 deletions(-) diff --git a/adev/src/content/guide/ssr.md b/adev/src/content/guide/ssr.md index 81128388c5f8..d5a0cc55580e 100644 --- a/adev/src/content/guide/ssr.md +++ b/adev/src/content/guide/ssr.md @@ -362,7 +362,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` or `Proxy-Authorization` headers. 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. You can override those settings by using `withHttpTransferCacheOptions` to the hydration configuration. ```ts import { bootstrapApplication } from '@angular/platform-browser'; @@ -416,7 +416,7 @@ Use this only when `POST` requests are **idempotent** and safe to reuse between ### `includeRequestsWithAuthHeaders` -Determines whether requests containing `Authorization` or `Proxy‑Authorization` headers are eligible for caching. +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. ```ts diff --git a/packages/common/http/src/transfer_cache.ts b/packages/common/http/src/transfer_cache.ts index c3cb85a82431..95fce76790a9 100644 --- a/packages/common/http/src/transfer_cache.ts +++ b/packages/common/http/src/transfer_cache.ts @@ -40,8 +40,9 @@ import {HttpParams} from './params'; * @param includePostRequests Enables caching for POST requests. By default, only GET and HEAD * requests are cached. This option can be enabled if POST requests are used to retrieve data * (for example using GraphQL). - * @param includeRequestsWithAuthHeaders Enables caching of requests containing either `Authorization` - * or `Proxy-Authorization` headers. By default, these requests are excluded from caching. + * @param includeRequestsWithAuthHeaders Enables caching of requests containing `Authorization`, + * `Proxy-Authorization`, or `Cookie` headers. By default, these requests are excluded from + * caching. * * @see [Configuring the caching options](guide/ssr#configuring-the-caching-options) * @@ -114,7 +115,7 @@ interface CacheOptions extends HttpTransferCacheOptions { isCacheActive: boolean; } -const CACHE_OPTIONS = new InjectionToken( +export const CACHE_OPTIONS = new InjectionToken( ngDevMode ? 'HTTP_TRANSFER_STATE_CACHE_OPTIONS' : '', ); @@ -137,7 +138,7 @@ export function transferCacheInterceptorFn( // POST requests are allowed either globally or at request level (requestMethod === 'POST' && !globalOptions.includePostRequests && !requestOptions) || (requestMethod !== 'POST' && !ALLOWED_METHODS.includes(requestMethod)) || - // Do not cache request that require authorization when includeRequestsWithAuthHeaders is falsey + // Do not cache requests with authentication or cookie headers unless explicitly enabled. (!globalOptions.includeRequestsWithAuthHeaders && hasAuthHeaders(req)) || globalOptions.filter?.(req) === false ) { @@ -241,9 +242,13 @@ export function transferCacheInterceptorFn( return event$; } -/** @returns true when the requests contains autorization related headers. */ +/** @returns true when the request contains authentication or cookie headers. */ function hasAuthHeaders(req: HttpRequest): boolean { - return req.headers.has('authorization') || req.headers.has('proxy-authorization'); + return ( + req.headers.has('authorization') || + req.headers.has('proxy-authorization') || + req.headers.has('cookie') + ); } function getFilteredHeaders( diff --git a/packages/common/http/test/transfer_cache_spec.ts b/packages/common/http/test/transfer_cache_spec.ts index b2aa699317c2..4b44c2b73612 100644 --- a/packages/common/http/test/transfer_cache_spec.ts +++ b/packages/common/http/test/transfer_cache_spec.ts @@ -17,17 +17,19 @@ import { } from '@angular/core'; import {fakeAsync, flush, TestBed} from '@angular/core/testing'; import {withBody} from '@angular/private/testing'; -import {BehaviorSubject} from 'rxjs'; +import {BehaviorSubject, Observable, of} from 'rxjs'; -import {HttpClient, HttpResponse, provideHttpClient} from '../public_api'; +import {HttpClient, HttpHeaders, HttpRequest, HttpResponse, provideHttpClient} from '../public_api'; import { BODY, + CACHE_OPTIONS, HEADERS, HTTP_TRANSFER_CACHE_ORIGIN_MAP, RESPONSE_TYPE, STATUS, STATUS_TEXT, REQ_URL, + transferCacheInterceptorFn, withHttpTransferCache, } from '../src/transfer_cache'; import {HttpTestingController, provideHttpClientTesting} from '../testing'; @@ -59,6 +61,102 @@ describe('TransferCache', () => { }) class SomeComponent {} + describe('transferCacheInterceptorFn', () => { + afterEach(() => { + TestBed.resetTestingModule(); + }); + + function configureInterceptor(options: {includeRequestsWithAuthHeaders?: boolean} = {}): void { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + TransferState, + { + provide: CACHE_OPTIONS, + useValue: { + isCacheActive: true, + ...options, + }, + }, + ], + }); + } + + function runOnServer(callback: () => T): T { + const previousServerMode = globalThis['ngServerMode']; + globalThis['ngServerMode'] = true; + try { + return callback(); + } finally { + globalThis['ngServerMode'] = previousServerMode; + } + } + + function runInterceptor( + req: HttpRequest, + next: (req: HttpRequest) => Observable>, + ): HttpResponse { + let response!: HttpResponse; + TestBed.runInInjectionContext(() => { + transferCacheInterceptorFn(req, next).subscribe((event) => { + if (event instanceof HttpResponse) { + response = event; + } + }); + }); + return response; + } + + it('should not reuse cached responses for Cookie-bearing requests by default', () => { + configureInterceptor(); + + const firstRequest = new HttpRequest('GET', '/test-cookie', null, { + headers: new HttpHeaders({Cookie: 'session=user-a'}), + }); + const secondRequest = new HttpRequest('GET', '/test-cookie', null, { + headers: new HttpHeaders({Cookie: 'session=user-b'}), + }); + + const firstNext = jasmine + .createSpy('firstNext') + .and.returnValue(of(new HttpResponse({body: 'user-a-secret'}))); + const secondNext = jasmine + .createSpy('secondNext') + .and.returnValue(of(new HttpResponse({body: 'user-b-secret'}))); + + runOnServer(() => { + expect(runInterceptor(firstRequest, firstNext).body).toBe('user-a-secret'); + expect(runInterceptor(secondRequest, secondNext).body).toBe('user-b-secret'); + }); + + expect(firstNext).toHaveBeenCalledTimes(1); + expect(secondNext).toHaveBeenCalledTimes(1); + }); + + it("should preserve opt-in caching for Cookie-bearing requests when 'includeRequestsWithAuthHeaders' is true", () => { + configureInterceptor({includeRequestsWithAuthHeaders: true}); + + const request = new HttpRequest('GET', '/test-cookie', null, { + headers: new HttpHeaders({Cookie: 'session=user-a'}), + }); + + const firstNext = jasmine + .createSpy('firstNext') + .and.returnValue(of(new HttpResponse({body: 'user-a-secret'}))); + const secondNext = jasmine + .createSpy('secondNext') + .and.returnValue(of(new HttpResponse({body: 'network-should-not-run'}))); + + runOnServer(() => { + expect(runInterceptor(request, firstNext).body).toBe('user-a-secret'); + expect(runInterceptor(request, secondNext).body).toBe('user-a-secret'); + }); + + expect(firstNext).toHaveBeenCalledTimes(1); + expect(secondNext).not.toHaveBeenCalled(); + }); + }); + describe('withHttpTransferCache', () => { let isStable: BehaviorSubject; From ec83ee2470f8a78ced749b5cd7464b2d7097989c Mon Sep 17 00:00:00 2001 From: Yenya030 Date: Tue, 26 May 2026 17:04:08 -0500 Subject: [PATCH 2/2] fix(http): exclude withCredentials requests from transfer cache Update the transfer cache check to safely exclude all requests sent with the `withCredentials` flag. By default, the HTTP transfer cache avoids caching user-specific responses to prevent sensitive data exposure or incorrect caching. While requests with explicit headers like `Cookie` or `Authorization` are excluded by default, requests can also be sent with credentials via the `withCredentials` flag without having those headers explicitly declared on the request object. To keep user-specific responses from being cached, exclude `withCredentials` requests unconditionally, even when the `includeRequestsWithAuthHeaders` option is set to true. --- adev/src/content/guide/ssr.md | 4 ++-- packages/common/http/src/transfer_cache.ts | 4 +++- .../common/http/test/transfer_cache_spec.ts | 21 +++++++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/adev/src/content/guide/ssr.md b/adev/src/content/guide/ssr.md index d5a0cc55580e..de2ccb671cae 100644 --- a/adev/src/content/guide/ssr.md +++ b/adev/src/content/guide/ssr.md @@ -362,7 +362,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. 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`. You can override those settings by using `withHttpTransferCacheOptions` to the hydration configuration. ```ts import { bootstrapApplication } from '@angular/platform-browser'; @@ -417,7 +417,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. +By default, these are excluded to prevent caching user‑specific responses. Requests sent with `withCredentials` 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 95fce76790a9..689a4e2ea024 100644 --- a/packages/common/http/src/transfer_cache.ts +++ b/packages/common/http/src/transfer_cache.ts @@ -42,7 +42,7 @@ 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. + * caching. Requests sent using `withCredentials` are also excluded by default. * * @see [Configuring the caching options](guide/ssr#configuring-the-caching-options) * @@ -135,6 +135,8 @@ export function transferCacheInterceptorFn( if ( !isCacheActive || requestOptions === false || + // Do not cache requests sent with credentials. + req.withCredentials || // POST requests are allowed either globally or at request level (requestMethod === 'POST' && !globalOptions.includePostRequests && !requestOptions) || (requestMethod !== 'POST' && !ALLOWED_METHODS.includes(requestMethod)) || diff --git a/packages/common/http/test/transfer_cache_spec.ts b/packages/common/http/test/transfer_cache_spec.ts index 4b44c2b73612..0d359087c6ce 100644 --- a/packages/common/http/test/transfer_cache_spec.ts +++ b/packages/common/http/test/transfer_cache_spec.ts @@ -40,6 +40,7 @@ interface RequestParams { observe?: 'body' | 'response'; transferCache?: {includeHeaders: string[]} | boolean; headers?: {[key: string]: string}; + withCredentials?: boolean; body?: RequestBody; } @@ -397,6 +398,16 @@ describe('TransferCache', () => { makeRequestAndExpectOne('/test-auth', 'foo'); }); + it('should not cache requests with credentials', async () => { + makeRequestAndExpectOne('/test-auth', 'foo', { + withCredentials: true, + }); + + makeRequestAndExpectOne('/test-auth', 'foo', { + withCredentials: true, + }); + }); + 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'}); @@ -549,6 +560,16 @@ describe('TransferCache', () => { makeRequestAndExpectNone('/test-auth'); }); + it(`should not cache requests with credentials when 'includeRequestsWithAuthHeaders' is 'true'`, async () => { + makeRequestAndExpectOne('/test-auth', 'foo', { + withCredentials: true, + }); + + makeRequestAndExpectOne('/test-auth', 'foo', { + withCredentials: true, + }); + }); + it('should cache a POST request', () => { makeRequestAndExpectOne('/include?foo=1', 'post-body', {method: 'POST'});