diff --git a/packages/angular/build/src/builders/dev-server/options.ts b/packages/angular/build/src/builders/dev-server/options.ts index 71210f9d7d98..a24ca9f5852f 100644 --- a/packages/angular/build/src/builders/dev-server/options.ts +++ b/packages/angular/build/src/builders/dev-server/options.ts @@ -125,8 +125,16 @@ export async function normalizeOptions( sslKey, prebundle, allowedHosts, + disableHostCheck, } = options; + let normalizedAllowedHosts: true | string[]; + if (disableHostCheck === true) { + normalizedAllowedHosts = true; + } else { + normalizedAllowedHosts = Array.isArray(allowedHosts) ? allowedHosts : []; + } + // Return all the normalized options return { buildTarget, @@ -152,6 +160,6 @@ export async function normalizeOptions( // Prebundling defaults to true but requires caching to function prebundle: cacheOptions.enabled && !optimization.scripts && prebundle, inspect, - allowedHosts: allowedHosts ? allowedHosts : [], + allowedHosts: normalizedAllowedHosts, }; } diff --git a/packages/angular/build/src/builders/dev-server/schema.json b/packages/angular/build/src/builders/dev-server/schema.json index 667bde5692da..de030c87f3da 100644 --- a/packages/angular/build/src/builders/dev-server/schema.json +++ b/packages/angular/build/src/builders/dev-server/schema.json @@ -37,21 +37,17 @@ "description": "SSL certificate to use for serving HTTPS." }, "allowedHosts": { - "description": "The hosts that the development server will respond to. This option sets the Vite option of the same name. For further details: https://vite.dev/config/server-options.html#server-allowedhosts", + "type": "array", + "description": "List of hosts that are allowed to access the dev server.", "default": [], - "oneOf": [ - { - "type": "array", - "description": "A list of hosts that the development server will respond to.", - "items": { - "type": "string" - } - }, - { - "type": "boolean", - "description": "Indicates that all hosts are allowed. This is not recommended and a security risk." - } - ] + "items": { + "type": "string" + } + }, + "disableHostCheck": { + "type": "boolean", + "description": "Don't verify connected clients are part of allowed hosts.", + "default": false }, "define": { "description": "Defines global identifiers that will be replaced with a specified constant value when found in any JavaScript or TypeScript code including libraries. The value will be used directly. String values must be put in quotes. Identifiers within Angular metadata such as Component Decorators will not be replaced.", diff --git a/packages/angular/build/src/builders/dev-server/tests/options/allowed-hosts_spec.ts b/packages/angular/build/src/builders/dev-server/tests/options/allowed-hosts_spec.ts index 775e057bece6..252b7f9e1880 100644 --- a/packages/angular/build/src/builders/dev-server/tests/options/allowed-hosts_spec.ts +++ b/packages/angular/build/src/builders/dev-server/tests/options/allowed-hosts_spec.ts @@ -65,19 +65,5 @@ describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupT expect(result?.success).toBeTrue(); expect(content).toContain(''); }); - - it('allows a host when option is true', async () => { - harness.useTarget('serve', { - ...BASE_OPTIONS, - allowedHosts: true, - }); - - const { result, content } = await executeOnceAndGet(harness, '/', { - request: { headers: FETCH_HEADERS }, - }); - - expect(result?.success).toBeTrue(); - expect(content).toContain('<title>'); - }); }); }); diff --git a/packages/angular/build/src/builders/dev-server/tests/options/disable-host-check_spec.ts b/packages/angular/build/src/builders/dev-server/tests/options/disable-host-check_spec.ts new file mode 100644 index 000000000000..c82eafe3d933 --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/tests/options/disable-host-check_spec.ts @@ -0,0 +1,69 @@ +/** + * @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 { executeDevServer } from '../../index'; +import { executeOnceAndGet } from '../execute-fetch'; +import { describeServeBuilder } from '../jasmine-helpers'; +import { BASE_OPTIONS, DEV_SERVER_BUILDER_INFO } from '../setup'; +import { text } from 'node:stream/consumers'; + +const FETCH_HEADERS = Object.freeze({ Host: 'example.com' }); + +describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupTarget) => { + describe('option: "disableHostCheck"', () => { + beforeEach(async () => { + setupTarget(harness); + + // Application code is not needed for these tests + await harness.writeFile('src/main.ts', ''); + }); + + it('does not allow an invalid host when option is not present', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndGet(harness, '/', { + request: { headers: FETCH_HEADERS }, + }); + + expect(result?.success).toBeTrue(); + expect(response?.statusCode).toBe(403); + expect(response && (await text(response))).toContain('angular.json'); + }); + + it('does not allow an invalid host when option is false', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + disableHostCheck: false, + }); + + const { result, response } = await executeOnceAndGet(harness, '/', { + request: { headers: FETCH_HEADERS }, + }); + + expect(result?.success).toBeTrue(); + expect(response?.statusCode).toBe(403); + expect(response && (await text(response))).toContain('angular.json'); + }); + + it('allows a host when option is true', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + disableHostCheck: true, + }); + + const { result, content } = await executeOnceAndGet(harness, '/', { + request: { headers: FETCH_HEADERS }, + }); + + expect(result?.success).toBeTrue(); + expect(content).toContain('<title>'); + }); + }); +}); diff --git a/packages/angular/cli/BUILD.bazel b/packages/angular/cli/BUILD.bazel index 2fbad284fd31..9b8bfd5fb79d 100644 --- a/packages/angular/cli/BUILD.bazel +++ b/packages/angular/cli/BUILD.bazel @@ -174,6 +174,7 @@ jasmine_test( "package.json", "test-esm-loader.mjs", ":angular-cli_test_lib", + "//packages/angular/build:schemas", ], node_options = [ "--import=./test-esm-loader.mjs", diff --git a/packages/angular/cli/src/command-builder/utilities/json-schema_spec.ts b/packages/angular/cli/src/command-builder/utilities/json-schema_spec.ts index 117b325f26b1..83b1dda61b8b 100644 --- a/packages/angular/cli/src/command-builder/utilities/json-schema_spec.ts +++ b/packages/angular/cli/src/command-builder/utilities/json-schema_spec.ts @@ -7,6 +7,8 @@ */ import { JsonObject, schema } from '@angular-devkit/core'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; import type { Argv } from 'yargs'; import yargs from 'yargs'; @@ -389,4 +391,42 @@ describe('parseJsonSchemaToOptions', () => { ]); }); }); + + describe('@angular/build dev-server schema', () => { + const devServerSchemaPath = path.join( + import.meta.dirname, + '../../../../build/src/builders/dev-server/schema.json', + ); + + const parse = async (args: string[]) => { + return localYargs.parseAsync(args); + }; + + let localYargs: Argv<unknown>; + + beforeEach(async () => { + localYargs = yargs().exitProcess(false).strict().fail(false).wrap(1_000); + + const devServerSchema = JSON.parse(readFileSync(devServerSchemaPath, 'utf8')) as JsonObject; + const registry = new schema.CoreSchemaRegistry(); + const options = await parseJsonSchemaToOptions(registry, devServerSchema, false); + addSchemaOptionsToCommand(localYargs, options, true); + }); + + it('parses --allowed-hosts with host names', async () => { + expect(await parse(['--allowed-hosts', 'example.com'])).toEqual( + jasmine.objectContaining({ + allowedHosts: ['example.com'], + }), + ); + }); + + it('parses --disable-host-check', async () => { + expect(await parse(['--disable-host-check'])).toEqual( + jasmine.objectContaining({ + disableHostCheck: true, + }), + ); + }); + }); });