Sitelet https://github.com/angular/angular/pull/68593/files
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: 2 additions & 0 deletions goldens/public-api/platform-server/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,15 @@ export function renderApplication(bootstrap: (context: BootstrapContext) => Prom
document?: string | Document;
url?: string;
platformProviders?: Provider[];
allowedHosts?: Readonly<string>[];
}): Promise<string>;

// @public
export function renderModule<T>(moduleType: Type<T>, options: {
document?: string | Document;
url?: string;
extraProviders?: StaticProvider[];
allowedHosts?: Readonly<string>[];
}): Promise<string>;

// @public
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ app.use((req, res) => {

renderApplication(bootstrap, {
document: indexHtml,
allowedHosts: ['localhost'],
url: `${protocol}://${headers.host}${originalUrl}`,
platformProviders: [{provide: APP_BASE_HREF, useValue: baseUrl}],
}).then((response: string) => {
Expand Down
1 change: 1 addition & 0 deletions integration/platform-server/projects/ngmodule/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ app.use((req, res) => {

renderModule(AppServerModule, {
document: indexHtml,
allowedHosts: ['localhost'],
url: `${protocol}://${headers.host}${originalUrl}`,
extraProviders: [{provide: APP_BASE_HREF, useValue: baseUrl}],
}).then((response: string) => {
Expand Down
1 change: 1 addition & 0 deletions integration/platform-server/projects/standalone/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ app.use((req, res) => {

renderApplication(bootstrap, {
document: indexHtml,
allowedHosts: ['localhost'],
url: `${protocol}://${headers.host}${originalUrl}`,
platformProviders: [{provide: APP_BASE_HREF, useValue: baseUrl}],
}).then((response: string) => {
Expand Down
3 changes: 3 additions & 0 deletions packages/platform-server/src/private_export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@ export {
export {SERVER_CONTEXT as ɵSERVER_CONTEXT, renderInternal as ɵrenderInternal} from './utils';
export {ENABLE_DOM_EMULATION as ɵENABLE_DOM_EMULATION} from './tokens';
export {DominoAdapter as ɵDominoAdapter} from './domino_adapter';

// Use in @angular/ssr.
export {isHostAllowed as ɵisHostAllowed} from './utils';
74 changes: 66 additions & 8 deletions packages/platform-server/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {BootstrapContext} from '@angular/platform-browser';

import {platformServer} from './server';
import {PlatformState} from './platform_state';
import {BEFORE_APP_SERIALIZED, INITIAL_CONFIG} from './tokens';
import {BEFORE_APP_SERIALIZED, INITIAL_CONFIG, PlatformConfig} from './tokens';
import {createScript} from './transfer_state';

/**
Expand All @@ -39,9 +39,8 @@ import {createScript} from './transfer_state';
*/
export const EVENT_DISPATCH_SCRIPT_ID = 'ng-event-dispatch-contract';

interface PlatformOptions {
interface PlatformOptions extends Omit<PlatformConfig, 'document'> {
document?: string | Document;
url?: string;
platformProviders?: Provider[];
}

Expand All @@ -53,9 +52,16 @@ function createServerPlatform(options: PlatformOptions): PlatformRef {
const extraProviders = options.platformProviders ?? [];
const measuringLabel = 'createServerPlatform';
startMeasuring(measuringLabel);
const {document, url} = options;

const platform = platformServer([
{provide: INITIAL_CONFIG, useValue: {document: options.document, url: options.url}},
{
provide: INITIAL_CONFIG,
useValue: {
document,
url,
},
},
extraProviders,
]);

Expand Down Expand Up @@ -265,14 +271,20 @@ function sanitizeServerContext(serverContext: string): string {
* as a reference to the `document` instance.
* - `url` - the URL for the current render request.
* - `extraProviders` - set of platform level providers for the current render request.
*
* - `allowedHosts` - the allowed hosts list for host validation in server-side rendering.
* @publicApi
*/
export async function renderModule<T>(
moduleType: Type<T>,
options: {document?: string | Document; url?: string; extraProviders?: StaticProvider[]},
options: {
document?: string | Document;
url?: string;
extraProviders?: StaticProvider[];
allowedHosts?: Readonly<string>[];
},
): Promise<string> {
const {document, url, extraProviders: platformProviders} = options;
const {document, url, extraProviders: platformProviders, allowedHosts} = options;
validateAllowedHosts(url, allowedHosts);
const platformRef = createServerPlatform({document, url, platformProviders});
try {
const moduleRef = await platformRef.bootstrapModule(moduleType);
Expand Down Expand Up @@ -315,18 +327,27 @@ export async function renderModule<T>(
* as a reference to the `document` instance.
* - `url` - the URL for the current render request.
* - `platformProviders` - the platform level providers for the current render request.
* - `allowedHosts` - the allowed hosts list for host validation in server-side rendering.
*
* @returns A Promise, that returns serialized (to a string) rendered page, once resolved.
*
* @publicApi
*/
export async function renderApplication(
bootstrap: (context: BootstrapContext) => Promise<ApplicationRef>,
options: {document?: string | Document; url?: string; platformProviders?: Provider[]},
options: {
document?: string | Document;
url?: string;
platformProviders?: Provider[];
allowedHosts?: Readonly<string>[];
},
): Promise<string> {
const renderAppLabel = 'renderApplication';
const bootstrapLabel = 'bootstrap';
const _renderLabel = '_render';
const {url, allowedHosts} = options;

validateAllowedHosts(url, allowedHosts);

startMeasuring(renderAppLabel);
const platformRef = createServerPlatform(options);
Expand All @@ -351,3 +372,40 @@ export async function renderApplication(
stopMeasuring(renderAppLabel);
}
}

function validateAllowedHosts(url: string | undefined, allowedHosts: string[] | undefined) {
if (typeof url === 'string' && URL.canParse(url)) {
const hostname = new url(/sitelet?url=https%3A%2F%2Fgithub.com%2Fangular%2Fangular%2Fpull%2F68593%2Furl).hostname;
const allowedHostsSet: ReadonlySet<string> = new Set(allowedHosts);
if (!isHostAllowed(hostname, allowedHostsSet)) {
throw new Error(`Host ${url} is not allowed. You can configure \`allowedHosts\` option.`);
}
}
}

/**
* Checks if the hostname is allowed.
* @param hostname - The hostname to check.
* @param allowedHosts - A set of allowed hostnames.
* @returns `true` if the hostname is allowed, `false` otherwise.
* @note Used also in `@angular/ssr`.
* @private
*/
export function isHostAllowed(hostname: string, allowedHosts: ReadonlySet<string>): boolean {
if (allowedHosts.has('*') || allowedHosts.has(hostname)) {
return true;
}

for (const allowedHost of allowedHosts) {
if (!allowedHost.startsWith('*.')) {
continue;
}

const domain = allowedHost.slice(1);
if (hostname.endsWith(domain)) {
return true;
}
}

return false;
}
97 changes: 97 additions & 0 deletions packages/platform-server/test/utils_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import {destroyPlatform} from '@angular/core';
import {renderApplication, renderModule} from '@angular/platform-server';
import {isHostAllowed} from '../src/utils';

describe('isHostAllowed', () => {
it('allows matching hostname when in allowedHosts list', () => {
expect(isHostAllowed('test.com', new Set(['test.com', 'example.com']))).toBeTrue();
});

it('allows matching hostname when wildcard matches', () => {
expect(isHostAllowed('sub.example.com', new Set(['test.com', '*.example.com']))).toBeTrue();
});

it('rejects hostname when not in allowedHosts list', () => {
expect(isHostAllowed('evil.com', new Set(['test.com', '*.example.com']))).toBeFalse();
});

it('allows all hostnames when * is in allowedHosts list', () => {
expect(isHostAllowed('anydomain.com', new Set(['*']))).toBeTrue();
});
});

describe('allowedHosts validation in renderApplication', () => {
const bootstrap = (async () => {}) as any;

beforeEach(() => {
destroyPlatform();
});

afterEach(() => {
destroyPlatform();
});

it('should throw an error on bootstrap if host is not allowed', async () => {
await expectAsync(
renderApplication(bootstrap, {
document: '<app></app>',
url: 'http://evil.com/deep/path',
allowedHosts: ['test.com', '*.example.com'],
}),
).toBeRejectedWithError(/Host http:\/\/evil.com\/deep\/path is not allowed/);
});

it('should not throw a host validation error on bootstrap if host is allowed', async () => {
try {
await renderApplication(bootstrap, {
document: '<app></app>',
url: 'http://test.com/deep/path',
allowedHosts: ['test.com', '*.example.com'],
});
} catch (error: any) {
expect(error.message).not.toContain('is not allowed');
}
});
});

describe('allowedHosts validation in renderModule', () => {
class MockModule {}

beforeEach(() => {
destroyPlatform();
});

afterEach(() => {
destroyPlatform();
});

it('should throw an error if host is not allowed', async () => {
await expectAsync(
renderModule(MockModule, {
document: '<app></app>',
url: 'http://evil.com/deep/path',
allowedHosts: ['test.com', '*.example.com'],
}),
).toBeRejectedWithError(/Host http:\/\/evil.com\/deep\/path is not allowed/);
});

it('should not throw a host validation error if host is allowed', async () => {
try {
await renderModule(MockModule, {
document: '<app></app>',
url: 'http://test.com/deep/path',
allowedHosts: ['test.com', '*.example.com'],
});
} catch (error: any) {
expect(error.message).not.toContain('is not allowed');
}
});
});
Loading