Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions adev/src/content/guide/ssr.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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({
Expand Down
19 changes: 13 additions & 6 deletions packages/common/http/src/transfer_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*
Expand Down Expand Up @@ -114,7 +115,7 @@ interface CacheOptions extends HttpTransferCacheOptions {
isCacheActive: boolean;
}

const CACHE_OPTIONS = new InjectionToken<CacheOptions>(
export const CACHE_OPTIONS = new InjectionToken<CacheOptions>(
ngDevMode ? 'HTTP_TRANSFER_STATE_CACHE_OPTIONS' : '',
);

Expand All @@ -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
) {
Expand Down Expand Up @@ -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<unknown>): 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(
Expand Down
123 changes: 121 additions & 2 deletions packages/common/http/test/transfer_cache_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -38,6 +40,7 @@ interface RequestParams {
observe?: 'body' | 'response';
transferCache?: {includeHeaders: string[]} | boolean;
headers?: {[key: string]: string};
withCredentials?: boolean;
body?: RequestBody;
}

Expand All @@ -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<T>(callback: () => T): T {
const previousServerMode = globalThis['ngServerMode'];
globalThis['ngServerMode'] = true;
try {
return callback();
} finally {
globalThis['ngServerMode'] = previousServerMode;
}
}

function runInterceptor(
req: HttpRequest<unknown>,
next: (req: HttpRequest<unknown>) => Observable<HttpResponse<unknown>>,
): HttpResponse<unknown> {
let response!: HttpResponse<unknown>;
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<boolean>;

Expand Down Expand Up @@ -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'});
Expand Down Expand Up @@ -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'});

Expand Down
Loading