@angular/common: Weak 32-Bit Cache Key Hashing in `HttpTransferCache` Leading to Cross-Request Data Leakage and State Poisoning
Description
Angular's HttpTransferCache uses a weak 32-bit hash for cache keys, allowing attackers to poison cached responses via hash collision, leading to state poisoning and potential XSS.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Angular's HttpTransferCache uses a weak 32-bit hash for cache keys, allowing attackers to poison cached responses via hash collision, leading to state poisoning and potential XSS.
Vulnerability
Angular's HttpTransferCache module, which caches HTTP requests made during Server-Side Rendering (SSR) for reuse during client-side hydration, generates cache keys using a weak 32-bit DJB2-like polynomial rolling hash [1][2]. This hash is applied to a combination of request properties: method, response type, mapped URL, serialized body, and sorted query parameters. The 32-bit hash space is extremely small, enabling attackers to compute collisions. The vulnerability affects all versions of Angular prior to the patches released in 22.0.1, 21.2.17, and 20.3.25 [1][2].
Exploitation
An attacker can craft a query parameter string (e.g., q=aaCAZMMM for a search request) that produces the exact same 32-bit hash as a sensitive endpoint (e.g., /api/user/profile) [1]. By tricking a victim into visiting a crafted link containing this colliding parameter, the SSR process executes both the attacker-controlled request and the sensitive request during rendering. Due to the hash collision, the response from the attacker's request overwrites the sensitive response in the TransferState cache [1][2]. No authentication is required if the target endpoint is accessible; the attack merely requires the victim to access a crafted URL during SSR.
Impact
Successful exploitation results in the application receiving an attacker-controlled response when the cached data for the sensitive endpoint is retrieved during client-side hydration [1][2]. This leads to state poisoning, where the application operates on forged data, potentially bypassing client-side security controls and enabling DOM-based Cross-Site Scripting (XSS) if the data is rendered unsafely [1][2]. Additionally, information leakage can occur if the victim's sensitive data is mistakenly associated with the attacker's response and exposed as part of the rendered page [1][2].
Mitigation
The Angular team released patched versions 22.0.1, 21.2.17, and 20.3.25 on or about June 15, 2026 [1][2]. The fix replaces the weak DJB2 hash with a cryptographically secure SHA-256 hash for cache key generation, implemented via a pure JavaScript synchronous SHA-256 algorithm [3][4]. For users who cannot upgrade immediately, a workaround is to disable transfer caching for sensitive endpoints by setting transferCache: false in the HttpClient options [1][2]. The vulnerability is not listed in the CISA KEV as of the publication date.
- CVE-2026-54266 - GitHub Advisory Database
- Weak 32-Bit Cache Key Hashing in `HttpTransferCache` Leading to Cross-Request Data Leakage and State Poisoning
- fix(common): use cryptographically secure SHA-256 for transfer cache key generation by alan-agius4 · Pull Request #69153 · angular/angular
- fix(common): use cryptographically secure SHA-256 for transfer cache … · angular/angular@5f36274
AI Insight generated on Jun 15, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1Patches
15f36274da3f9fix(common): use cryptographically secure SHA-256 for transfer cache key generation
3 files changed · +303 −113
packages/common/http/src/transfer_cache.ts+267 −109 modified@@ -103,13 +103,13 @@ interface TransferHttpResponse { /** headers */ [HEADERS]: Record<string, string[]>; /** status */ - [STATUS]?: number; + [STATUS]: number; /** statusText */ - [STATUS_TEXT]?: string; + [STATUS_TEXT]: string; /** url */ - [REQ_URL]?: string; + [REQ_URL]: string; /** responseType */ - [RESPONSE_TYPE]?: HttpRequest<unknown>['responseType']; + [RESPONSE_TYPE]: HttpRequest<unknown>['responseType']; } interface CacheOptions extends HttpTransferCacheOptions { @@ -125,7 +125,7 @@ export const CACHE_OPTIONS = new InjectionToken<CacheOptions>( */ const ALLOWED_METHODS = ['GET', 'HEAD']; -function shouldCacheRequest(req: HttpRequest<unknown>, options: CacheOptions): boolean { +function canUseOrCacheRequest(req: HttpRequest<unknown>, options: CacheOptions): boolean { const {isCacheActive, ...globalOptions} = options; const {transferCache: requestOptions, method: requestMethod} = req; @@ -155,25 +155,30 @@ function getHeadersToInclude( options: CacheOptions, requestOptions: HttpTransferCacheOptions | boolean | undefined, ): string[] | undefined { - const {includeHeaders: globalHeaders} = options; - let headersToInclude = globalHeaders; - if (typeof requestOptions === 'object' && requestOptions.includeHeaders) { - // Request-specific config takes precedence over the global config. - headersToInclude = requestOptions.includeHeaders; - } - return headersToInclude; + // Request-specific config takes precedence over the global config. + return typeof requestOptions === 'object' && requestOptions.includeHeaders + ? requestOptions.includeHeaders + : options.includeHeaders; } +/** + * Retrieves the cached response for a given request. + * @param req The request to retrieve the cached response for. + * @param options The caching options. + * @param transferState The transfer state to retrieve the cached response from. + * @param originMap The origin map to map the request URL to the origin. (Not needed when `storeKey` is provided). + * @param storeKey The key to use to store the cached response in the transfer state. (If not provided, it will be computed from the request and originMap). + * @param skipUseCacheChecks Whether to skip the use cache checks. (Only disable when the checks have been performed beforehand). + */ export function retrieveStateFromCache( req: HttpRequest<unknown>, options: CacheOptions, transferState: TransferState, originMap: Record<string, string> | null, + storeKey?: StateKey<TransferHttpResponse>, + skipUseCacheChecks = false, ): HttpResponse<unknown> | null { - const {transferCache: requestOptions} = req; - - // In the following situations we do not want to cache the request - if (!shouldCacheRequest(req, options)) { + if (!skipUseCacheChecks && !canUseOrCacheRequest(req, options)) { return null; } @@ -187,107 +192,118 @@ export function retrieveStateFromCache( ); } - const requestUrl = - typeof ngServerMode !== 'undefined' && ngServerMode && originMap - ? mapRequestOriginUrl(req.url, originMap) - : req.url; + if (!storeKey) { + const requestUrl = + typeof ngServerMode !== 'undefined' && ngServerMode && originMap + ? mapRequestOriginUrl(req.url, originMap) + : req.url; + + storeKey = makeCacheKey(req, requestUrl); + } - const storeKey = makeCacheKey(req, requestUrl); const response = transferState.get(storeKey, null); - const headersToInclude = getHeadersToInclude(options, requestOptions); - - if (response) { - const { - [BODY]: undecodedBody, - [RESPONSE_TYPE]: responseType, - [HEADERS]: httpHeaders, - [STATUS]: status, - [STATUS_TEXT]: statusText, - [REQ_URL]: url, - } = response; - // Request found in cache. Respond using it. - let body: ArrayBuffer | Blob | string | undefined = undecodedBody; - - switch (responseType) { - case 'arraybuffer': - body = fromBase64(undecodedBody); - break; - case 'blob': - body = new Blob([fromBase64(undecodedBody)]); - break; - } + if (!response) { + return null; + } - // We want to warn users accessing a header provided from the cache - // That HttpTransferCache alters the headers - // The warning will be logged a single time by HttpHeaders instance - let headers = new HttpHeaders(httpHeaders); - if (typeof ngDevMode === 'undefined' || ngDevMode) { - // Append extra logic in dev mode to produce a warning when a header - // that was not transferred to the client is accessed in the code via `get` - // and `has` calls. - headers = appendMissingHeadersDetection(req.url, headers, headersToInclude ?? []); - } + const { + [BODY]: undecodedBody, + [RESPONSE_TYPE]: responseType, + [HEADERS]: httpHeaders, + [STATUS]: status, + [STATUS_TEXT]: statusText, + [REQ_URL]: url, + } = response; + // Request found in cache. Respond using it. + let body: ArrayBuffer | Blob | string | undefined = undecodedBody; + + switch (responseType) { + case 'arraybuffer': + body = fromBase64(undecodedBody); + break; + case 'blob': + body = new Blob([fromBase64(undecodedBody)]); + break; + } - return new HttpResponse({ - body, - headers, - status, - statusText, - url, - }); + // We want to warn users accessing a header provided from the cache + // That HttpTransferCache alters the headers + // The warning will be logged a single time by HttpHeaders instance + let headers = new HttpHeaders(httpHeaders); + if (typeof ngDevMode === 'undefined' || ngDevMode) { + // Append extra logic in dev mode to produce a warning when a header + // that was not transferred to the client is accessed in the code via `get` + // and `has` calls. + const {transferCache: requestOptions} = req; + const headersToInclude = getHeadersToInclude(options, requestOptions); + headers = appendMissingHeadersDetection(req.url, headers, headersToInclude ?? []); } - return null; + return new HttpResponse({ + body, + headers, + status, + statusText, + url, + }); } export function transferCacheInterceptorFn( req: HttpRequest<unknown>, next: HttpHandlerFn, ): Observable<HttpEvent<unknown>> { const options = inject(CACHE_OPTIONS); - const transferState = inject(TransferState); - - const originMap = inject(HTTP_TRANSFER_CACHE_ORIGIN_MAP, {optional: true}); - - const cachedResponse = retrieveStateFromCache(req, options, transferState, originMap); - if (cachedResponse) { - return of(cachedResponse); + if (!canUseOrCacheRequest(req, options)) { + return next(req); } - const {transferCache: requestOptions} = req; - const headersToInclude = getHeadersToInclude(options, requestOptions); - + const transferState = inject(TransferState); + const originMap = inject(HTTP_TRANSFER_CACHE_ORIGIN_MAP, {optional: true}); const requestUrl = typeof ngServerMode !== 'undefined' && ngServerMode && originMap ? mapRequestOriginUrl(req.url, originMap) : req.url; const storeKey = makeCacheKey(req, requestUrl); - // In the following situations we do not want to cache the request - if (!shouldCacheRequest(req, options)) { - return next(req); + const cachedResponse = retrieveStateFromCache( + req, + options, + transferState, + /** originMap */ null, + storeKey, + /** skipUseCacheChecks */ true, + ); + + if (cachedResponse) { + return of(cachedResponse); } const event$ = next(req); - if (typeof ngServerMode !== 'undefined' && ngServerMode) { // 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 that do not have Cache-Control - // directives that forbid shared caching (no-store or private). - if (event instanceof HttpResponse && !hasUncacheableCacheControl(event.headers)) { + if (event instanceof HttpResponse) { + const {headers, body, status, statusText} = event; + + // Only cache successful HTTP responses that do not have Cache-Control + // directives that forbid shared caching (no-store or private). + if (hasUncacheableCacheControl(headers)) { + return; + } + + const {transferCache: requestOptions, responseType} = req; + const headersToInclude = getHeadersToInclude(options, requestOptions); + transferState.set<TransferHttpResponse>(storeKey, { [BODY]: - req.responseType === 'arraybuffer' || req.responseType === 'blob' - ? toBase64(event.body) - : event.body, - [HEADERS]: getFilteredHeaders(event.headers, headersToInclude), - [STATUS]: event.status, - [STATUS_TEXT]: event.statusText, + responseType === 'arraybuffer' || responseType === 'blob' ? toBase64(body) : body, + [HEADERS]: getFilteredHeaders(headers, headersToInclude), + [STATUS]: status, + [STATUS_TEXT]: statusText, [REQ_URL]: requestUrl, - [RESPONSE_TYPE]: req.responseType, + [RESPONSE_TYPE]: responseType, }); } }), @@ -299,10 +315,10 @@ export function transferCacheInterceptorFn( /** @returns true when the request contains authentication or cookie headers. */ function hasAuthHeaders(req: HttpRequest<unknown>): boolean { + const headers = req.headers; + return ( - req.headers.has('authorization') || - req.headers.has('proxy-authorization') || - req.headers.has('cookie') + headers.has('authorization') || headers.has('proxy-authorization') || headers.has('cookie') ); } @@ -327,7 +343,9 @@ function isNonCacheableRequest(cache: RequestCache): boolean { } function hasOutgoingCredentials(req: HttpRequest<unknown>): boolean { - return req.withCredentials || req.credentials === 'include' || req.credentials === 'same-origin'; + const {withCredentials, credentials} = req; + + return withCredentials || credentials === 'include' || credentials === 'same-origin'; } function getFilteredHeaders( @@ -377,26 +395,6 @@ function makeCacheKey( return makeStateKey(hash); } -/** - * A method that returns a hash representation of a string using a variant of DJB2 hash - * algorithm. - * - * This is the same hashing logic that is used to generate component ids. - */ -function generateHash(value: string): string { - let hash = 0; - - for (const char of value) { - hash = (Math.imul(31, hash) + char.charCodeAt(0)) << 0; - } - - // Force positive number hash. - // 2147483647 = equivalent of Integer.MAX_VALUE. - hash += 2147483647 + 1; - - return hash.toString(); -} - function toBase64(buffer: unknown): string { //TODO: replace with when is Baseline widely available // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array/toBase64 @@ -534,3 +532,163 @@ function verifyMappedOrigin(url: string): void { ); } } + +/** + * SHA-256 Constants (first 32 bits of the fractional parts of the cube roots of the first 64 primes 2..311): + */ +const SHA256_ROUND_CONSTANTS = /* @__PURE__ */ new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +let textEncoder: TextEncoder | undefined; + +/** + * Generates a SHA-256 hash representation of a string. + * + * 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 + * state lookup and interceptor flow must operate synchronously due to the HttpResource API. + * + * The previous DJB2 hashing logic was vulnerable to pre-image and second-preimage attacks due to + * its small 64-bit keyspace and mathematical simplicity. An attacker could craft colliding request + * inputs to poison the cache, potentially causing a CDN or the application to serve the wrong + * 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); + + // Initial hash values (first 32 bits of the fractional parts of the square roots of the first 8 primes 2..19): + let hashState0 = 0x6a09e667; + let hashState1 = 0xbb67ae85; + let hashState2 = 0x3c6ef372; + let hashState3 = 0xa54ff53a; + let hashState4 = 0x510e527f; + let hashState5 = 0x9b05688c; + let hashState6 = 0x1f83d9ab; + let hashState7 = 0x5be0cd19; + + // Pre-processing (Padding): + const messageLengthInBits = inputBytes.length * 8; + + // The total length of the padded message must be a multiple of 64 bytes (512 bits) + const paddedLengthInBytes = (((inputBytes.length + 8) >> 6) + 1) << 6; + const paddedBytes = new Uint8Array(paddedLengthInBytes); + paddedBytes.set(inputBytes); + paddedBytes[inputBytes.length] = 0x80; // Append a single '1' bit (0x80 byte) + + const paddedBytesView = new DataView(paddedBytes.buffer); + const lowBits = messageLengthInBits >>> 0; + const highBits = (messageLengthInBits / 0x100000000) >>> 0; + paddedBytesView.setUint32(paddedLengthInBytes - 8, highBits, false); + paddedBytesView.setUint32(paddedLengthInBytes - 4, lowBits, false); + + // Process the message in successive 64-byte chunks: + const messageSchedule = new Uint32Array(64); + for (let chunkOffset = 0; chunkOffset < paddedLengthInBytes; chunkOffset += 64) { + // Initialize first 16 words of the message schedule: + for (let i = 0; i < 16; i++) { + messageSchedule[i] = paddedBytesView.getUint32(chunkOffset + i * 4, false); + } + + // Extend to 64 words: + for (let i = 16; i < 64; i++) { + const prevWord15 = messageSchedule[i - 15]; + const sigma0 = + (((prevWord15 >>> 7) | (prevWord15 << 25)) ^ + ((prevWord15 >>> 18) | (prevWord15 << 14)) ^ + (prevWord15 >>> 3)) >>> + 0; + + const prevWord2 = messageSchedule[i - 2]; + const sigma1 = + (((prevWord2 >>> 17) | (prevWord2 << 15)) ^ + ((prevWord2 >>> 19) | (prevWord2 << 13)) ^ + (prevWord2 >>> 10)) >>> + 0; + + messageSchedule[i] = + (messageSchedule[i - 16] + sigma0 + messageSchedule[i - 7] + sigma1) >>> 0; + } + + // Initialize working variables to current hash values: + let workingStateA = hashState0; + let workingStateB = hashState1; + let workingStateC = hashState2; + let workingStateD = hashState3; + let workingStateE = hashState4; + let workingStateF = hashState5; + let workingStateG = hashState6; + let workingStateH = hashState7; + + // Compression function main loop: + for (let i = 0; i < 64; i++) { + const capitalSigma1 = + (((workingStateE >>> 6) | (workingStateE << 26)) ^ + ((workingStateE >>> 11) | (workingStateE << 21)) ^ + ((workingStateE >>> 25) | (workingStateE << 7))) >>> + 0; + const chFunction = ((workingStateE & workingStateF) ^ (~workingStateE & workingStateG)) >>> 0; + const temp1 = + (workingStateH + + capitalSigma1 + + chFunction + + SHA256_ROUND_CONSTANTS[i] + + messageSchedule[i]) >>> + 0; + + const capitalSigma0 = + (((workingStateA >>> 2) | (workingStateA << 30)) ^ + ((workingStateA >>> 13) | (workingStateA << 19)) ^ + ((workingStateA >>> 22) | (workingStateA << 10))) >>> + 0; + const majFunction = + ((workingStateA & workingStateB) ^ + (workingStateA & workingStateC) ^ + (workingStateB & workingStateC)) >>> + 0; + const temp2 = (capitalSigma0 + majFunction) >>> 0; + + workingStateH = workingStateG; + workingStateG = workingStateF; + workingStateF = workingStateE; + workingStateE = (workingStateD + temp1) >>> 0; + workingStateD = workingStateC; + workingStateC = workingStateB; + workingStateB = workingStateA; + workingStateA = (temp1 + temp2) >>> 0; + } + + // Update intermediate hash state: + hashState0 = (hashState0 + workingStateA) >>> 0; + hashState1 = (hashState1 + workingStateB) >>> 0; + hashState2 = (hashState2 + workingStateC) >>> 0; + hashState3 = (hashState3 + workingStateD) >>> 0; + hashState4 = (hashState4 + workingStateE) >>> 0; + hashState5 = (hashState5 + workingStateF) >>> 0; + hashState6 = (hashState6 + workingStateG) >>> 0; + hashState7 = (hashState7 + workingStateH) >>> 0; + } + + // Produce the final 64-character hexadecimal hash: + return [ + hashState0, + hashState1, + hashState2, + hashState3, + hashState4, + hashState5, + hashState6, + hashState7, + ] + .map((x) => x.toString(16).padStart(8, '0')) + .join(''); +}
packages/common/http/test/transfer_cache_spec.ts+33 −2 modified@@ -31,6 +31,7 @@ import { REQ_URL, transferCacheInterceptorFn, withHttpTransferCache, + generateHash, } from '../src/transfer_cache'; import {HttpTestingController, provideHttpClientTesting} from '../testing'; import {PLATFORM_BROWSER_ID, PLATFORM_SERVER_ID} from '../../src/platform_id'; @@ -385,15 +386,15 @@ describe('TransferCache', () => { const transferState = TestBed.inject(TransferState); expect(JSON.parse(transferState.toJson()) as Record<string, unknown>).toEqual({ - '2400571479': { + '2da5dfaf112523258ec9c26a0abe9a093b59ed7dbe5f43e4b5ee25a407ac9cf0': { [BODY]: 'foo', [HEADERS]: {}, [STATUS]: 200, [STATUS_TEXT]: 'OK', [REQ_URL]: '/test-1', [RESPONSE_TYPE]: 'json', }, - '2400572440': { + '869485290d9385f3c0a9ba571918c335bbca9e03373bf8260d02f2b7dd335849': { [BODY]: 'buzz', [HEADERS]: {}, [STATUS]: 200, @@ -1065,4 +1066,34 @@ describe('TransferCache', () => { }); }); }); + + describe('generateHash', () => { + async function computeNativeSha256(value: string): Promise<string> { + const msgUint8 = new TextEncoder().encode(value); + const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + } + + it('should generate standard SHA-256 hashes matching Web Crypto specs', async () => { + const testCases = [ + '', + 'hello', + 'angular', + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + 'Angular 🚀', + 'a'.repeat(55), + 'a'.repeat(56), + 'a'.repeat(63), + 'a'.repeat(64), + 'a'.repeat(65), + 'a'.repeat(1000), + ]; + + for (const testCase of testCases) { + const expected = await computeNativeSha256(testCase); + expect(generateHash(testCase)).withContext(`For: ${testCase}`).toBe(expected); + } + }); + }); });
packages/core/test/bundling/hydration/bundle.golden_symbols.json+3 −2 modified@@ -247,6 +247,7 @@ "RuntimeError", "SCHEDULE_IN_ROOT_ZONE", "SCHEDULE_IN_ROOT_ZONE_DEFAULT", + "SHA256_ROUND_CONSTANTS", "SHARED_STYLES_HOST", "SIGNAL", "SIMPLE_CHANGES_STORE", @@ -392,6 +393,7 @@ "callHookInternal", "callHooks", "canHydrateNode", + "canUseOrCacheRequest", "cancelLeavingNodes", "captureError", "checkStable", @@ -571,7 +573,6 @@ "getFirstLContainer", "getFirstNativeNode", "getGlobalLocale", - "getHeadersToInclude", "getHooks", "getInheritedInjectableDef", "getInitialLViewFlagsFromDef", @@ -942,7 +943,6 @@ "shimStylesContent", "shouldAddViewToDom", "shouldBeIgnoredByZone", - "shouldCacheRequest", "shouldEnableEventReplay", "shouldPreventDefaultBeforeDispatching", "shouldSearchParent", @@ -956,6 +956,7 @@ "stringifyCSSSelectorList", "subscribeOn", "syncViewWithBlueprint", + "textEncoder", "throwInvalidWriteToSignalErrorFn", "throwProviderNotFoundError", "timeoutProvider",
Vulnerability mechanics
Synthesis attempt was rejected by the grounding validator. Re-run pending.
References
4News mentions
0No linked articles in our index yet.