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
2 changes: 1 addition & 1 deletion adev/src/content/guide/ssr.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Hydration is the process that restores the server side rendered application on t

[`HttpClient`](api/common/http/HttpClient) cached outgoing network requests when running on the server. This information is serialized and transferred to the browser as part of the initial HTML sent from the server. In the browser, `HttpClient` checks whether it has data in the cache and if so, reuses it instead of making a new HTTP request during initial application rendering. `HttpClient` stops using the cache once an application becomes [stable](api/core/ApplicationRef#isStable) while running in a browser.

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`](api/platform-browser/withHttpTransferCacheOptions) when providing hydration.
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`](api/platform-browser/withHttpTransferCacheOptions) when providing hydration.

```typescript
bootstrapApplication(AppComponent, {
Expand Down
2 changes: 2 additions & 0 deletions goldens/public-api/core/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1679,6 +1679,8 @@ export interface SchemaMetadata {

// @public
export enum SecurityContext {
// (undocumented)
ATTRIBUTE_NO_BINDING = 6,
// (undocumented)
HTML = 1,
// (undocumented)
Expand Down
2 changes: 1 addition & 1 deletion goldens/size-tracking/integration-payloads.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
},
"cli-hello-world-ivy-i18n": {
"uncompressed": {
"main": 135813,
"main": 143035,
"polyfills": 35883
}
},
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.
*
* @publicApi
*/
Expand Down Expand Up @@ -112,7 +113,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 @@ -132,10 +133,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 @@ -232,9 +235,13 @@ export function transferCacheInterceptorFn(
);
}

/** @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 @@ -447,6 +556,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
42 changes: 4 additions & 38 deletions packages/compiler-cli/test/ngtsc/ngtsc_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8180,9 +8180,6 @@ runInEachFileSystem((os: string) => {
@HostBinding('attr.action')
attrAction: string;

@HostBinding('attr.profile')
attrProfile: string;

@HostBinding('attr.innerHTML')
attrInnerHTML: string;

Expand All @@ -8209,10 +8206,10 @@ runInEachFileSystem((os: string) => {
env.driveMain();
const jsContents = env.getContents('test.js');
const hostBindingsFn = `
hostVars: 6,
hostVars: 5,
hostBindings: function UnsafeAttrsDirective_HostBindings(rf, ctx) {
if (rf & 2) {
i0.ɵɵattribute("href", ctx.attrHref, i0.ɵɵsanitizeUrlOrResourceUrl)("src", ctx.attrSrc, i0.ɵɵsanitizeUrlOrResourceUrl)("action", ctx.attrAction, i0.ɵɵsanitizeUrl)("profile", ctx.attrProfile, i0.ɵɵsanitizeResourceUrl)("innerHTML", ctx.attrInnerHTML, i0.ɵɵsanitizeHtml)("title", ctx.attrSafeTitle);
i0.ɵɵattribute("href", ctx.attrHref, i0.ɵɵsanitizeUrlOrResourceUrl)("src", ctx.attrSrc, i0.ɵɵsanitizeUrlOrResourceUrl)("action", ctx.attrAction, i0.ɵɵsanitizeUrl)("innerHTML", ctx.attrInnerHTML, i0.ɵɵsanitizeHtml)("title", ctx.attrSafeTitle);
}
}
`;
Expand All @@ -8239,9 +8236,6 @@ runInEachFileSystem((os: string) => {
@HostBinding('action')
propAction: string;

@HostBinding('profile')
propProfile: string;

@HostBinding('innerHTML')
propInnerHTML: string;

Expand All @@ -8268,44 +8262,16 @@ runInEachFileSystem((os: string) => {
env.driveMain();
const jsContents = env.getContents('test.js');
const hostBindingsFn = `
hostVars: 6,
hostVars: 5,
hostBindings: function UnsafePropsDirective_HostBindings(rf, ctx) {
if (rf & 2) {
i0.ɵɵhostProperty("href", ctx.propHref, i0.ɵɵsanitizeUrlOrResourceUrl)("src", ctx.propSrc, i0.ɵɵsanitizeUrlOrResourceUrl)("action", ctx.propAction, i0.ɵɵsanitizeUrl)("profile", ctx.propProfile, i0.ɵɵsanitizeResourceUrl)("innerHTML", ctx.propInnerHTML, i0.ɵɵsanitizeHtml)("title", ctx.propSafeTitle);
i0.ɵɵhostProperty("href", ctx.propHref, i0.ɵɵsanitizeUrlOrResourceUrl)("src", ctx.propSrc, i0.ɵɵsanitizeUrlOrResourceUrl)("action", ctx.propAction, i0.ɵɵsanitizeUrl)("innerHTML", ctx.propInnerHTML, i0.ɵɵsanitizeHtml)("title", ctx.propSafeTitle);
}
}
`;
expect(trim(jsContents)).toContain(trim(hostBindingsFn));
});

it('should generate sanitizers for URL properties in SVG script fn in Component', () => {
env.write(
'test.ts',
`
import {Component} from '@angular/core';

@Component({
selector: 'test-cmp',
template: \`
<svg>
<script [attr.xlink:href]="attr" [attr.href]="attr"></script>
</svg>
\`,
})
export class TestCmp {
attr = './script.js';
}
`,
);

env.driveMain();

const jsContents = env.getContents('test.js');
expect(jsContents).toContain(
'i0.ɵɵattribute("href", ctx.attr, i0.ɵɵsanitizeResourceUrl, "xlink")("href", ctx.attr, i0.ɵɵsanitizeResourceUrl);',
);
});

it('should not generate sanitizers for URL properties in hostBindings fn in Component', () => {
env.write(
`test.ts`,
Expand Down
1 change: 0 additions & 1 deletion packages/compiler/src/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import * as core from './core';
import {publishFacade} from './jit_compiler_facade';
import {global} from './util';

export {SECURITY_SCHEMA} from './schema/dom_security_schema';
export {CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA, SchemaMetadata} from './core';
export {core};

Expand Down
12 changes: 2 additions & 10 deletions packages/compiler/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,16 +76,6 @@ export interface Type extends Function {
}
export const Type = Function;

export enum SecurityContext {
NONE = 0,
HTML = 1,
STYLE = 2,
SCRIPT = 3,
URL = 4,
RESOURCE_URL = 5,
ATTRIBUTE_NO_BINDING = 6,
}

/**
* Injection flags for DI.
*/
Expand Down Expand Up @@ -328,3 +318,5 @@ export const enum AttributeMarker {
*/
I18n = 6,
}

export {SecurityContext} from './schema/dom_security_schema';
Loading
Loading