diff --git a/adev/src/content/guide/ssr.md b/adev/src/content/guide/ssr.md index 7fa99205adb3..a9122cff9c6f 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. 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` @@ -487,7 +489,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({ @@ -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 3993e61bc833..2b55e8c2987d 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,12 +133,16 @@ 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)) || // 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; @@ -270,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' @@ -300,6 +306,30 @@ function hasAuthHeaders(req: HttpRequest): boolean { ); } +function hasOutgoingCredentials(req: HttpRequest): boolean { + return req.withCredentials || req.credentials === 'include' || req.credentials === 'same-origin'; +} + +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.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 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..cd51ad97e352 100644 --- a/packages/common/http/test/transfer_cache_spec.ts +++ b/packages/common/http/test/transfer_cache_spec.ts @@ -40,7 +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; } @@ -157,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', () => { @@ -177,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; } @@ -440,6 +544,146 @@ 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 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'}); @@ -602,6 +846,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'}); diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index 229a34850d13..c577e904a4f9 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -231,6 +231,7 @@ "TracingAction", "TracingService", "TransferState", + "UNCACHEABLE_CACHE_CONTROL_DIRECTIVES", "USE_VALUE", "UnsubscriptionError", "VIEW_REFS", @@ -532,9 +533,11 @@ "hasLift", "hasMatchingDehydratedView", "hasOnDestroy", + "hasOutgoingCredentials", "hasParentInjector", "hasSkipHydrationAttrOnRElement", "hasSkipHydrationAttrOnTNode", + "hasUncacheableCacheControl", "icuContainerIterate", "identity", "importProvidersFrom", @@ -593,6 +596,7 @@ "isIterable", "isLContainer", "isLView", + "isNonCacheableRequest", "isNotFound", "isObserver", "isPositive", @@ -807,4 +811,4 @@ ], "lazy": [] } -} \ No newline at end of file +}