From 4f4bc43ece7177230a666b11e315b54ab9ac7596 Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:03:43 -0500 Subject: [PATCH] fix(http): distinguish binary transfer cache request bodies Include the serialized body type and ArrayBuffer bytes in transfer cache keys. Avoid caching Blob and FormData requests when a complete synchronous identity cannot be derived. Prevent distinct protobuf and gRPC POST payloads from sharing a cache entry during hydration. Fixes #70226 --- adev/src/content/guide/ssr.md | 2 + .../platform-server-hydration/size.json | 2 +- packages/common/http/src/transfer_cache.ts | 56 +++++++++++--- .../common/http/test/transfer_cache_spec.ts | 74 ++++++++++++++++++- 4 files changed, 119 insertions(+), 15 deletions(-) diff --git a/adev/src/content/guide/ssr.md b/adev/src/content/guide/ssr.md index 5f20d64482fd..1628673428ef 100644 --- a/adev/src/content/guide/ssr.md +++ b/adev/src/content/guide/ssr.md @@ -502,6 +502,8 @@ withHttpTransferCacheOptions({ }); ``` +Angular includes `ArrayBuffer` bytes in the cache key. Requests with `Blob` or `FormData` bodies are not cached because Angular cannot derive a complete cache key for them synchronously. Caching them without their contents could cause different request bodies to share the same cached response. + Use this only when `POST` requests are **idempotent** and safe to reuse between server and client renders. ### `includeRequestsWithAuthHeaders` diff --git a/integration/platform-server-hydration/size.json b/integration/platform-server-hydration/size.json index 34c6400d7bbc..4309fb9111a4 100644 --- a/integration/platform-server-hydration/size.json +++ b/integration/platform-server-hydration/size.json @@ -1,5 +1,5 @@ { - "dist/browser/main-[hash].js": 237605, + "dist/browser/main-[hash].js": 242626, "dist/browser/polyfills-[hash].js": 35784, "dist/browser/event-dispatch-contract.min.js": 476 } diff --git a/packages/common/http/src/transfer_cache.ts b/packages/common/http/src/transfer_cache.ts index 4c92a84fca10..918b1df05743 100644 --- a/packages/common/http/src/transfer_cache.ts +++ b/packages/common/http/src/transfer_cache.ts @@ -52,6 +52,8 @@ export interface HttpTransferCacheOptions { /** * 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`). + * Requests with `Blob` or `FormData` bodies are not cached because Angular cannot derive a + * complete cache key for them synchronously across supported runtimes. */ includePostRequests?: boolean; @@ -224,7 +226,11 @@ export function retrieveStateFromCache( ? mapRequestOriginUrl(req.url, originMap) : req.url; - storeKey = makeCacheKey(req, requestUrl); + const cacheKey = makeCacheKey(req, requestUrl); + if (cacheKey === null) { + return null; + } + storeKey = cacheKey; } const response = transferState.get(storeKey, null); @@ -291,6 +297,10 @@ export function transferCacheInterceptorFn( ? mapRequestOriginUrl(req.url, originMap) : req.url; const storeKey = makeCacheKey(req, requestUrl); + if (storeKey === null) { + // Requests without a complete body key bypass the transfer cache to prevent collisions. + return next(req); + } const cachedResponse = retrieveStateFromCache( req, @@ -410,21 +420,43 @@ function sortAndConcatParams(params: HttpParams | URLSearchParams): string { function makeCacheKey( request: HttpRequest, mappedRequestUrl: string, -): StateKey { +): StateKey | null { const {params, method, responseType} = request; const encodedParams = sortAndConcatParams(params); - let serializedBody = request.serializeBody(); - if (serializedBody instanceof URLSearchParams) { - serializedBody = sortAndConcatParams(serializedBody); - } else if (typeof serializedBody !== 'string') { - serializedBody = ''; + const serializedBody = request.serializeBody(); + let bodyType: string; + let bodyForCacheKey: string; + if (serializedBody === null) { + bodyType = 'null'; + bodyForCacheKey = ''; + } else if (serializedBody instanceof URLSearchParams) { + bodyType = 'urlSearchParams'; + bodyForCacheKey = sortAndConcatParams(serializedBody); + } else if (serializedBody instanceof ArrayBuffer) { + bodyType = 'arrayBuffer'; + // Hash the bytes directly to avoid creating a larger base64 cache-key input. + bodyForCacheKey = generateHash(new Uint8Array(serializedBody)); + } else if (typeof serializedBody === 'string') { + bodyType = 'string'; + bodyForCacheKey = serializedBody; + } else { + // Blob and FormData cannot provide a complete cache key synchronously. Skipping them + // prevents different request bodies from sharing a cache entry. + return null; } // Joining with `|` lets a shifted field boundary (url `/a` + body `b|c` vs url `/a|b` + body `c`) // collapse to the same string and thus the same hash. `\0` cannot occur in a valid url or in // encoded params, so the field boundaries can't be forged by field content. - const key = [method, responseType, mappedRequestUrl, serializedBody, encodedParams].join('\0'); + const key = [ + method, + responseType, + mappedRequestUrl, + bodyType, + bodyForCacheKey, + encodedParams, + ].join('\0'); const hash = generateHash(key); return makeStateKey(hash); @@ -585,7 +617,7 @@ const SHA256_ROUND_CONSTANTS = /* @__PURE__ */ new Uint32Array([ let textEncoder: TextEncoder | undefined; /** - * Generates a SHA-256 hash representation of a string. + * Generates a SHA-256 hash representation of a string or byte array. * * Note: A custom synchronous SHA-256 implementation is used here because the Web Crypto API * (`crypto.subtle.digest`) is strictly asynchronous (Promise-based), whereas the transfer cache @@ -597,9 +629,9 @@ let textEncoder: TextEncoder | undefined; * cached response to legitimate users. SHA-256 provides strong cryptographic collision resistance, * preventing cache key collision attacks. */ -export function generateHash(value: string): string { - textEncoder ??= new TextEncoder(); - const inputBytes = textEncoder.encode(value); +export function generateHash(value: string | Uint8Array): string { + const inputBytes = + typeof value === 'string' ? (textEncoder ??= new TextEncoder()).encode(value) : value; // Initial hash values (first 32 bits of the fractional parts of the square roots of the first 8 primes 2..19): let hashState0 = 0x6a09e667; diff --git a/packages/common/http/test/transfer_cache_spec.ts b/packages/common/http/test/transfer_cache_spec.ts index de4d316bf89d..742827e4ce1e 100644 --- a/packages/common/http/test/transfer_cache_spec.ts +++ b/packages/common/http/test/transfer_cache_spec.ts @@ -541,7 +541,7 @@ describe('TransferCache', () => { const transferState = TestBed.inject(TransferState); expect(JSON.parse(transferState.toJson()) as Record).toEqual({ - 'd501aa2d57b63a95df74e3b0558782b71b077974e968ed303cd30b27e4b70702': { + '0b6e4c00c4fc4ff7474413ec4eee9ad06986ba020ea9435ede66801414fe12ee': { [BODY]: 'foo', [HEADERS]: {}, [STATUS]: 200, @@ -549,7 +549,7 @@ describe('TransferCache', () => { [REQ_URL]: '/test-1', [RESPONSE_TYPE]: 'json', }, - 'ceddc6689dc1f2fc3a0b8c364b6e00a79b99a149f27e84da87cec03d44c150c8': { + '16af864024ff480eb7b907c8d06909577f956909b85e2a02d0602ca8b5c4bb4f': { [BODY]: 'buzz', [HEADERS]: {}, [STATUS]: 200, @@ -926,6 +926,76 @@ describe('TransferCache', () => { }); }); + it('should differentiate POST requests with ArrayBuffer bodies', () => { + const firstBody = new Uint8Array([1, 2, 3]).buffer; + const equivalentBody = new Uint8Array([1, 2, 3]).buffer; + const differentBody = new Uint8Array([4, 5, 6]).buffer; + + makeRequestAndExpectOne('/test-arraybuffer-body', 'first', { + method: 'POST', + transferCache: true, + body: firstBody, + }); + const cachedResponse = makeRequestAndExpectNone('/test-arraybuffer-body', 'POST', { + transferCache: true, + body: equivalentBody, + }); + expect(cachedResponse.body).toBe('first'); + makeRequestAndExpectOne('/test-arraybuffer-body', 'second', { + method: 'POST', + transferCache: true, + body: differentBody, + }); + }); + + it('should differentiate POST requests with null and empty string bodies', () => { + makeRequestAndExpectOne('/test-empty-body', 'null-body', { + method: 'POST', + transferCache: true, + body: null, + }); + makeRequestAndExpectNone('/test-empty-body', 'POST', { + transferCache: true, + body: null, + }); + makeRequestAndExpectOne('/test-empty-body', 'empty-string-body', { + method: 'POST', + transferCache: true, + body: '', + }); + }); + + it('should not cache POST requests with Blob bodies', () => { + const body = new Blob(['test']); + + makeRequestAndExpectOne('/test-blob-body', 'first', { + method: 'POST', + transferCache: true, + body, + }); + makeRequestAndExpectOne('/test-blob-body', 'second', { + method: 'POST', + transferCache: true, + body, + }); + }); + + it('should not cache POST requests with FormData bodies', () => { + const body = new FormData(); + body.set('field', 'value'); + + makeRequestAndExpectOne('/test-form-data-body', 'first', { + method: 'POST', + transferCache: true, + body, + }); + makeRequestAndExpectOne('/test-form-data-body', 'second', { + method: 'POST', + transferCache: true, + body, + }); + }); + describe('caching in browser context', () => { beforeEach(() => { globalThis['ngServerMode'] = false;