diff --git a/adev/src/content/guide/ssr.md b/adev/src/content/guide/ssr.md index 81128388c5f8..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` 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 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'; @@ -416,8 +416,8 @@ 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. -By default, these are excluded to prevent caching user‑specific responses. +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. ```ts withHttpTransferCacheOptions({ diff --git a/packages/common/http/src/transfer_cache.ts b/packages/common/http/src/transfer_cache.ts index c3cb85a82431..689a4e2ea024 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. Requests sent using `withCredentials` are also excluded by default. * * @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' : '', ); @@ -134,10 +135,12 @@ 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)) || - // 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 +244,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..0d359087c6ce 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'; @@ -38,6 +40,7 @@ interface RequestParams { observe?: 'body' | 'response'; transferCache?: {includeHeaders: string[]} | boolean; headers?: {[key: string]: string}; + withCredentials?: boolean; body?: RequestBody; } @@ -59,6 +62,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; @@ -299,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'}); @@ -451,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'});