diff --git a/packages/common/http/src/transfer_cache.ts b/packages/common/http/src/transfer_cache.ts index 1871eada37ec..d3864aa2b467 100644 --- a/packages/common/http/src/transfer_cache.ts +++ b/packages/common/http/src/transfer_cache.ts @@ -103,13 +103,13 @@ interface TransferHttpResponse { /** headers */ [HEADERS]: Record; /** status */ - [STATUS]?: number; + [STATUS]: number; /** statusText */ - [STATUS_TEXT]?: string; + [STATUS_TEXT]: string; /** url */ - [REQ_URL]?: string; + [REQ_URL]: string; /** responseType */ - [RESPONSE_TYPE]?: HttpRequest['responseType']; + [RESPONSE_TYPE]: HttpRequest['responseType']; } interface CacheOptions extends HttpTransferCacheOptions { @@ -125,7 +125,7 @@ export const CACHE_OPTIONS = new InjectionToken( */ const ALLOWED_METHODS = ['GET', 'HEAD']; -function shouldCacheRequest(req: HttpRequest, options: CacheOptions): boolean { +function canUseOrCacheRequest(req: HttpRequest, 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, options: CacheOptions, transferState: TransferState, originMap: Record | null, + storeKey?: StateKey, + skipUseCacheChecks = false, ): HttpResponse | 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; + + 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( @@ -246,48 +254,56 @@ export function transferCacheInterceptorFn( next: HttpHandlerFn, ): Observable> { 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) => { - // 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(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): 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): 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(''); +} diff --git a/packages/common/http/test/transfer_cache_spec.ts b/packages/common/http/test/transfer_cache_spec.ts index cd51ad97e352..79cd88413ca6 100644 --- a/packages/common/http/test/transfer_cache_spec.ts +++ b/packages/common/http/test/transfer_cache_spec.ts @@ -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).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 { + 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); + } + }); + }); }); diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index 29316d94a5d7..ca17249fc4ae 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -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",