fix(common): use cryptographically secure SHA-256 for transfer cache key generation

Replace the custom 64-bit non-cryptographic combined DJB2 hashing implementation in HttpTransferCache with a robust, pure JavaScript, synchronous SHA-256 algorithm.

Using DJB2 is 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. A custom synchronous implementation is required because the Web Crypto API (`crypto.subtle.digest`) is asynchronous, whereas the transfer cache state lookup and interceptor flow must operate synchronously.

Also, update the unit tests to dynamically verify the custom SHA-256 output against the native Web Crypto API.
This commit is contained in:
Alan Agius
2026-06-04 13:14:39 +00:00
committed by Andrew Scott
parent d846326b07
commit bc55749698
4 changed files with 310 additions and 120 deletions
@@ -1,5 +1,5 @@
{
"dist/browser/main-[hash].js": 227093,
"dist/browser/polyfills-[hash].js": 34544,
"dist/browser/main-[hash].js": 233303,
"dist/browser/polyfills-[hash].js": 35677,
"dist/browser/event-dispatch-contract.min.js": 476
}
+271 -113
View File
@@ -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,58 +192,61 @@ 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;
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;
}
// 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 ?? []);
}
return new HttpResponse({
body,
headers,
status,
statusText,
url,
});
storeKey = makeCacheKey(req, requestUrl);
}
return null;
const response = transferState.get(storeKey, null);
if (!response) {
return null;
}
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;
}
// 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 new HttpResponse({
body,
headers,
status,
statusText,
url,
});
}
export function transferCacheInterceptorFn(
@@ -246,48 +254,56 @@ export function transferCacheInterceptorFn(
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,15 +315,17 @@ 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')
);
}
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';
}
const UNCACHEABLE_CACHE_CONTROL_DIRECTIVES = new Set(['no-store', 'private', 'no-cache']);
@@ -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('');
}
@@ -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,7 +386,7 @@ describe('TransferCache', () => {
const transferState = TestBed.inject(TransferState);
expect(JSON.parse(transferState.toJson()) as Record<string, unknown>).toEqual({
'2400571479': {
'2da5dfaf112523258ec9c26a0abe9a093b59ed7dbe5f43e4b5ee25a407ac9cf0': {
[BODY]: 'foo',
[HEADERS]: {},
[STATUS]: 200,
@@ -393,7 +394,7 @@ describe('TransferCache', () => {
[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);
}
});
});
});
@@ -199,6 +199,7 @@
"RuntimeError",
"SCHEDULE_IN_ROOT_ZONE",
"SCHEDULE_IN_ROOT_ZONE_DEFAULT",
"SHA256_ROUND_CONSTANTS",
"SIGNAL",
"SIMPLE_CHANGES_STORE",
"SKIP_HYDRATION_ATTR_NAME",
@@ -325,6 +326,7 @@
"callHookInternal",
"callHooks",
"canHydrateNode",
"canUseOrCacheRequest",
"cancelLeavingNodes",
"captureError",
"checkStable",
@@ -474,7 +476,6 @@
"getFactoryDef",
"getFirstLContainer",
"getGlobalLocale",
"getHeadersToInclude",
"getInheritedInjectableDef",
"getInitialLViewFlagsFromDef",
"getInjectFlag",
@@ -774,7 +775,6 @@
"shimHostAttribute",
"shimStylesContent",
"shouldBeIgnoredByZone",
"shouldCacheRequest",
"shouldSearchParent",
"siblingAfter",
"skipTextNodes",
@@ -784,6 +784,7 @@
"stringifyCSSSelectorList",
"subscribeOn",
"syncViewWithBlueprint",
"textEncoder",
"throwInvalidWriteToSignalErrorFn",
"throwProviderNotFoundError",
"timeoutProvider",
@@ -811,4 +812,4 @@
],
"lazy": []
}
}
}