mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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`.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -139,6 +139,10 @@ function shouldCacheRequest(req: HttpRequest<unknown>, 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<unknown>) => {
|
||||
// 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<TransferHttpResponse>(storeKey, {
|
||||
[BODY]:
|
||||
req.responseType === 'arraybuffer' || req.responseType === 'blob'
|
||||
@@ -301,6 +306,26 @@ function hasAuthHeaders(req: HttpRequest<unknown>): 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<unknown>): boolean {
|
||||
return req.withCredentials || req.credentials === 'include' || req.credentials === 'same-origin';
|
||||
}
|
||||
|
||||
@@ -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'});
|
||||
|
||||
Reference in New Issue
Block a user