diff --git a/packages/schematics/angular/migrations/migration-collection.json b/packages/schematics/angular/migrations/migration-collection.json index 12f0d2b7d215..db1b95f987ef 100644 --- a/packages/schematics/angular/migrations/migration-collection.json +++ b/packages/schematics/angular/migrations/migration-collection.json @@ -4,7 +4,7 @@ "add-istanbul-instrumenter": { "version": "22.0.0", "factory": "./add-istanbul-instrumenter/migration", - "description": "Add istanbul-lib-instrument to devDependencies if Karma unit testing is used." + "description": "Add 'istanbul-lib-instrument' to 'devDependencies' if Karma unit testing is used." }, "use-application-builder": { "version": "22.0.0", @@ -20,6 +20,11 @@ "description": "Migrate projects using legacy Karma unit-test builder to the new unit-test builder with Vitest.", "optional": true }, + "trust-proxy-headers": { + "version": "22.0.0", + "factory": "./trust-proxy-headers/migration", + "description": "Add 'trustProxyHeaders' configuration to 'AngularNodeAppEngine' or 'AngularAppEngine'. For more information see: https://angular.dev/best-practices/security#configuring-trusted-proxy-headers" + }, "update-workspace-config": { "version": "22.0.0", "factory": "./update-workspace-config/migration", diff --git a/packages/schematics/angular/migrations/trust-proxy-headers/migration.ts b/packages/schematics/angular/migrations/trust-proxy-headers/migration.ts new file mode 100644 index 000000000000..9be44b03039b --- /dev/null +++ b/packages/schematics/angular/migrations/trust-proxy-headers/migration.ts @@ -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 { Rule } from '@angular-devkit/schematics'; +import ts from 'typescript'; +import { allTargetOptions, allWorkspaceTargets, getWorkspace } from '../../utility/workspace'; + +const TODO_COMMENT = + '// TODO: This is a security-sensitive option. Remove if not needed. ' + + 'For more information, see https://angular.dev/best-practices/security#configuring-trusted-proxy-headers'; + +export default function (): Rule { + return async (tree) => { + const workspace = await getWorkspace(tree); + const serverFiles = new Set(); + + for (const [targetName, target] of allWorkspaceTargets(workspace)) { + if (targetName !== 'build') { + continue; + } + + for (const [, options] of allTargetOptions(target)) { + if (typeof options?.['server'] === 'string') { + serverFiles.add(options['server']); + } + } + } + + for (const path of serverFiles) { + if (!tree.exists(path)) { + continue; + } + + const content = tree.readText(path); + if (content.includes(TODO_COMMENT)) { + continue; + } + + if (!content.includes('AngularAppEngine') && !content.includes('AngularNodeAppEngine')) { + continue; + } + + const sourceFile = ts.createSourceFile(path, content, ts.ScriptTarget.Latest, true); + const recorder = tree.beginUpdate(path); + + function visit(node: ts.Node) { + if ( + ts.isNewExpression(node) && + ts.isIdentifier(node.expression) && + (node.expression.text === 'AngularNodeAppEngine' || + node.expression.text === 'AngularAppEngine') + ) { + // Check arguments + if (!node.arguments || node.arguments.length === 0) { + // Case 1: No arguments passed + const insertPos = node.end - 1; // right before ) + recorder.insertRight( + insertPos, + `{\n ${TODO_COMMENT}\n ` + + `trustProxyHeaders: ['x-forwarded-host', 'x-forwarded-proto'],\n}`, + ); + } else if (node.arguments.length > 0) { + const firstArg = node.arguments[0]; + if (ts.isObjectLiteralExpression(firstArg)) { + // Check if trustProxyHeaders is already present + const hasTrustProxyHeaders = firstArg.properties.some( + (prop: ts.ObjectLiteralElementLike) => + ts.isPropertyAssignment(prop) && + (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) && + prop.name.text === 'trustProxyHeaders', + ); + + if (!hasTrustProxyHeaders) { + // Insert right after the opening brace + const insertPos = firstArg.getStart() + 1; + recorder.insertRight( + insertPos, + `\n ${TODO_COMMENT}\n ` + + `trustProxyHeaders: ['x-forwarded-host', 'x-forwarded-proto'],`, + ); + } + } + } + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + tree.commitUpdate(recorder); + } + }; +} diff --git a/packages/schematics/angular/migrations/trust-proxy-headers/migration_spec.ts b/packages/schematics/angular/migrations/trust-proxy-headers/migration_spec.ts new file mode 100644 index 000000000000..3e6b7ab613ea --- /dev/null +++ b/packages/schematics/angular/migrations/trust-proxy-headers/migration_spec.ts @@ -0,0 +1,101 @@ +/** + * @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 { EmptyTree } from '@angular-devkit/schematics'; +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; + +function createWorkSpaceConfig(tree: UnitTestTree) { + const angularConfig = { + version: 1, + projects: { + app: { + root: '', + sourceRoot: 'src', + projectType: 'application', + architect: { + build: { + options: { + server: '/server.ts', + }, + }, + }, + }, + }, + }; + + tree.create('/angular.json', JSON.stringify(angularConfig, undefined, 2)); +} + +describe(`Migration to add trustProxyHeaders to server.ts`, () => { + const schematicName = 'trust-proxy-headers'; + const schematicRunner = new SchematicTestRunner( + 'migrations', + require.resolve('../migration-collection.json'), + ); + const TODO_COMMENT = + '// TODO: This is a security-sensitive option. Remove if not needed. ' + + 'For more information, see https://angular.dev/best-practices/security#configuring-trusted-proxy-headers'; + + let tree: UnitTestTree; + beforeEach(() => { + tree = new UnitTestTree(new EmptyTree()); + createWorkSpaceConfig(tree); + }); + + it(`should add trustProxyHeaders to AngularNodeAppEngine with no args`, async () => { + tree.create( + '/server.ts', + `import { AngularNodeAppEngine } from '@angular/ssr/node';\nconst angularApp = new AngularNodeAppEngine();`, + ); + + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const content = newTree.readText('/server.ts'); + expect(content).toContain(`const angularApp = new AngularNodeAppEngine({`); + expect(content).toContain(TODO_COMMENT); + expect(content).toContain(`trustProxyHeaders: ['x-forwarded-host', 'x-forwarded-proto'],`); + }); + + it(`should add trustProxyHeaders to AngularNodeAppEngine with existing args`, async () => { + tree.create( + '/server.ts', + `import { AngularNodeAppEngine } from '@angular/ssr/node';\n` + + `const angularApp = new AngularNodeAppEngine({\n allowedHosts: ['localhost']\n});`, + ); + + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const content = newTree.readText('/server.ts'); + expect(content).toContain(`const angularApp = new AngularNodeAppEngine({`); + expect(content).toContain(TODO_COMMENT); + expect(content).toContain(`trustProxyHeaders: ['x-forwarded-host', 'x-forwarded-proto'],`); + expect(content).toContain(`allowedHosts: ['localhost']`); + }); + + it(`should add trustProxyHeaders to AngularAppEngine`, async () => { + tree.create( + '/server.ts', + `import { AngularAppEngine } from '@angular/ssr';\nconst angularApp = new AngularAppEngine();`, + ); + + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const content = newTree.readText('/server.ts'); + expect(content).toContain(`const angularApp = new AngularAppEngine({`); + expect(content).toContain(TODO_COMMENT); + expect(content).toContain(`trustProxyHeaders: ['x-forwarded-host', 'x-forwarded-proto'],`); + }); + + it(`should not add trustProxyHeaders if it already exists`, async () => { + const originalContent = + `import { AngularAppEngine } from '@angular/ssr';\n` + + `const angularApp = new AngularAppEngine({\n trustProxyHeaders: true\n});`; + tree.create('/server.ts', originalContent); + + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const content = newTree.readText('/server.ts'); + expect(content).toBe(originalContent); + }); +});