diff --git a/src/deploy/apphosting/prepare.spec.ts b/src/deploy/apphosting/prepare.spec.ts index 7ad479efdb4..be93db59d0e 100644 --- a/src/deploy/apphosting/prepare.spec.ts +++ b/src/deploy/apphosting/prepare.spec.ts @@ -16,6 +16,7 @@ import prepare, { getBackendConfigs, injectEnvVarsFromApphostingConfig, injectAutoInitEnvVars, + injectAngularEnvVars, } from "./prepare"; import * as localbuilds from "../../apphosting/localbuilds"; import * as managementApps from "../../management/apps"; @@ -29,6 +30,7 @@ import { Options } from "../../options"; import { AppHostingSingle } from "../../firebaseConfig"; import * as fs from "fs"; import * as fsAsync from "../../fsAsync"; +import * as utils from "../../utils"; const BASE_OPTS = { cwd: "/", @@ -811,4 +813,247 @@ describe("apphosting", () => { expect(runtimeEnv["foo"]["AUTO_VAR_1"]?.value).to.equal("auto1"); }); }); + + describe("injectAngularEnvVars", () => { + let existsStub: sinon.SinonStub; + let readFileSyncStub: sinon.SinonStub; + + beforeEach(() => { + existsStub = fs.existsSync as sinon.SinonStub; + readFileSyncStub = sinon.stub(fs, "readFileSync"); + }); + + it("should do nothing for non-Angular applications", async () => { + const cfg: AppHostingSingle = { backendId: "foo", rootDir: "/", ignore: [] }; + const buildEnv: Record = { foo: {} }; + const runtimeEnv: Record = { foo: {} }; + + existsStub.returns(false); + + await injectAngularEnvVars( + cfg, + "/app-dir", + "my-project", + "us-central1", + buildEnv, + runtimeEnv, + ); + + expect(buildEnv["foo"]).to.be.empty; + expect(runtimeEnv["foo"]).to.be.empty; + }); + + it("should inject defaults for Angular applications when headers are missing", async () => { + const cfg: AppHostingSingle = { backendId: "foo", rootDir: "/", ignore: [] }; + const buildEnv: Record = { foo: {} }; + const runtimeEnv: Record = { foo: {} }; + + existsStub.withArgs(sinon.match("package.json")).returns(true); + readFileSyncStub.withArgs(sinon.match("package.json")).returns( + JSON.stringify({ + dependencies: { + "@angular/core": "^19.0.0", + }, + }), + ); + + await injectAngularEnvVars( + cfg, + "/app-dir", + "my-project", + "us-central1", + buildEnv, + runtimeEnv, + ); + + expect(runtimeEnv["foo"]["NG_TRUST_PROXY_HEADERS"]).to.deep.equal({ + value: "x-forwarded-host,x-forwarded-port,x-forwarded-proto,x-forwarded-for", + availability: ["RUNTIME"], + }); + expect(runtimeEnv["foo"]["NG_ALLOWED_HOSTS"]).to.deep.equal({ + value: + "foo-123456789.us-central1.run.app,foo--my-project.us-central1.hosted.app,foo--my-project.web.app,foo--my-project.firebaseapp.com", + availability: ["RUNTIME"], + }); + }); + + it("should NOT override user-defined NG_TRUST_PROXY_HEADERS if it is a subset of allowed values", async () => { + const cfg: AppHostingSingle = { backendId: "foo", rootDir: "/", ignore: [] }; + const buildEnv: Record = { foo: {} }; + const runtimeEnv: Record = { + foo: { + NG_TRUST_PROXY_HEADERS: { + value: "x-forwarded-host,x-forwarded-proto", + availability: ["RUNTIME"], + }, + }, + }; + + existsStub.withArgs(sinon.match("package.json")).returns(true); + readFileSyncStub.returns( + JSON.stringify({ + dependencies: { "@angular/core": "^19.0.0" }, + }), + ); + + const warningSpy = sinon.spy(utils, "logLabeledWarning"); + + await injectAngularEnvVars( + cfg, + "/app-dir", + "my-project", + "us-central1", + buildEnv, + runtimeEnv, + ); + + expect(runtimeEnv["foo"]["NG_TRUST_PROXY_HEADERS"]).to.deep.equal({ + value: "x-forwarded-host,x-forwarded-proto", + availability: ["RUNTIME"], + }); + expect(warningSpy).to.not.have.been.called; + }); + + it("should throw a FirebaseError if user-defined NG_TRUST_PROXY_HEADERS contains invalid headers", async () => { + const cfg: AppHostingSingle = { backendId: "foo", rootDir: "/", ignore: [] }; + const buildEnv: Record = { foo: {} }; + const runtimeEnv: Record = { + foo: { + NG_TRUST_PROXY_HEADERS: { + value: "x-forwarded-host,invalid-header", + availability: ["RUNTIME"], + }, + }, + }; + + existsStub.withArgs(sinon.match("package.json")).returns(true); + readFileSyncStub.returns( + JSON.stringify({ + dependencies: { "@angular/core": "^19.0.0" }, + }), + ); + + await expect( + injectAngularEnvVars(cfg, "/app-dir", "my-project", "us-central1", buildEnv, runtimeEnv), + ).to.be.rejectedWith( + FirebaseError, + /User-defined RUNTIME environment variable NG_TRUST_PROXY_HEADERS contains invalid headers/, + ); + }); + + it("should override user-defined NG_TRUST_PROXY_HEADERS but NOT log a warning if defined as BUILD-only variable", async () => { + const cfg: AppHostingSingle = { backendId: "foo", rootDir: "/", ignore: [] }; + const buildEnv: Record = { + foo: { + NG_TRUST_PROXY_HEADERS: { + value: "x-forwarded-host,x-forwarded-proto", + availability: ["BUILD"], + }, + }, + }; + const runtimeEnv: Record = { foo: {} }; + + existsStub.withArgs(sinon.match("package.json")).returns(true); + readFileSyncStub.returns( + JSON.stringify({ + dependencies: { "@angular/core": "^19.0.0" }, + }), + ); + + const warningSpy = sinon.spy(utils, "logLabeledWarning"); + + await injectAngularEnvVars( + cfg, + "/app-dir", + "my-project", + "us-central1", + buildEnv, + runtimeEnv, + ); + + expect(runtimeEnv["foo"]["NG_TRUST_PROXY_HEADERS"]).to.deep.equal({ + value: "x-forwarded-host,x-forwarded-port,x-forwarded-proto,x-forwarded-for", + availability: ["RUNTIME"], + }); + expect(buildEnv["foo"]["NG_TRUST_PROXY_HEADERS"]).to.deep.equal({ + value: "x-forwarded-host,x-forwarded-proto", + availability: ["BUILD"], + }); + expect(warningSpy).to.not.have.been.called; + }); + + it("should NOT inject default NG_ALLOWED_HOSTS if user has defined it as RUNTIME variable", async () => { + const cfg: AppHostingSingle = { backendId: "foo", rootDir: "/", ignore: [] }; + const buildEnv: Record = { foo: {} }; + const runtimeEnv: Record = { + foo: { + NG_ALLOWED_HOSTS: { + value: "MY-CUSTOM-DOMAIN.COM", + availability: ["RUNTIME"], + }, + }, + }; + + existsStub.withArgs(sinon.match("package.json")).returns(true); + readFileSyncStub.returns( + JSON.stringify({ + dependencies: { "@angular/core": "^19.0.0" }, + }), + ); + + await injectAngularEnvVars( + cfg, + "/app-dir", + "my-project", + "us-central1", + buildEnv, + runtimeEnv, + ); + + expect(runtimeEnv["foo"]["NG_ALLOWED_HOSTS"]).to.deep.equal({ + value: "MY-CUSTOM-DOMAIN.COM", + availability: ["RUNTIME"], + }); + expect(buildEnv["foo"]["NG_ALLOWED_HOSTS"]).to.be.undefined; + }); + + it("should inject default NG_ALLOWED_HOSTS into runtimeEnv if user defined it as a BUILD-only variable", async () => { + const cfg: AppHostingSingle = { backendId: "foo", rootDir: "/", ignore: [] }; + const buildEnv: Record = { + foo: { + NG_ALLOWED_HOSTS: { + value: "MY-CUSTOM-DOMAIN.COM,foo-123456789.us-central1.run.app,Another-Domain.com", + availability: ["BUILD"], + }, + }, + }; + const runtimeEnv: Record = { foo: {} }; + + existsStub.withArgs(sinon.match("package.json")).returns(true); + readFileSyncStub.returns( + JSON.stringify({ + dependencies: { "@angular/core": "^19.0.0" }, + }), + ); + + await injectAngularEnvVars( + cfg, + "/app-dir", + "my-project", + "us-central1", + buildEnv, + runtimeEnv, + ); + + expect(runtimeEnv["foo"]["NG_ALLOWED_HOSTS"]).to.deep.equal({ + value: + "foo-123456789.us-central1.run.app,foo--my-project.us-central1.hosted.app,foo--my-project.web.app,foo--my-project.firebaseapp.com", + availability: ["RUNTIME"], + }); + expect(buildEnv["foo"]["NG_ALLOWED_HOSTS"]).to.deep.equal({ + value: "MY-CUSTOM-DOMAIN.COM,foo-123456789.us-central1.run.app,Another-Domain.com", + availability: ["BUILD"], + }); + }); + }); }); diff --git a/src/deploy/apphosting/prepare.ts b/src/deploy/apphosting/prepare.ts index 7bc30ce40a1..8ccbf36fc1e 100644 --- a/src/deploy/apphosting/prepare.ts +++ b/src/deploy/apphosting/prepare.ts @@ -27,7 +27,7 @@ import { Options } from "../../options"; import { needProjectId } from "../../projectUtils"; import { getProjectNumber } from "../../getProjectNumber"; import { checkbox, confirm } from "../../prompt"; -import { logLabeledBullet, logLabeledWarning } from "../../utils"; +import { logLabeledBullet, logLabeledWarning, logLabeledError } from "../../utils"; import { localBuild } from "../../apphosting/localbuilds"; import { Context } from "./args"; import { FirebaseError } from "../../error"; @@ -198,6 +198,8 @@ export default async function (context: Context, options: Options): Promise; + devDependencies?: Record; +} + +/** + * Returns true if the directory contains an Angular application. + */ +function isAngularApplication(appDir: string): boolean { + const packageJsonPath = path.join(appDir, "package.json"); + if (fs.existsSync(packageJsonPath)) { + try { + const parsed: unknown = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + if (parsed && typeof parsed === "object") { + const pkg = parsed as PackageJson; + const angularDeps = ["@angular/core", "@angular/ssr", "@angular/platform-server"]; + if (angularDeps.some((d) => pkg.dependencies?.[d] || pkg.devDependencies?.[d])) { + return true; + } + } + } catch (e: unknown) { + logLabeledError( + "apphosting", + `error when checking if application is angular: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + const angularJsonPath = path.join(appDir, "angular.json"); + if (fs.existsSync(angularJsonPath)) { + return true; + } + return false; +} + +/** + * Replicates the Go buildpack preparer's Angular environment variable injection and validation. + */ +export async function injectAngularEnvVars( + cfg: AppHostingSingle, + projectRoot: string, + projectId: string, + location: string | undefined, + buildEnv: Record, + runtimeEnv: Record, +): Promise { + const appDir = path.join(projectRoot, cfg.rootDir || ""); + if (!isAngularApplication(appDir)) { + return; + } + + if (!location) { + throw new FirebaseError(`Failed to find location for backend ${cfg.backendId}.`); + } + + const backendId = cfg.backendId; + runtimeEnv[backendId] ??= {}; + buildEnv[backendId] ??= {}; + + const allowedProxyHeaders = [ + "x-forwarded-host", + "x-forwarded-port", + "x-forwarded-proto", + "x-forwarded-for", + ]; + const allowedProxyHeadersValue = allowedProxyHeaders.join(","); + + // 1. Inject NG_TRUST_PROXY_HEADERS + let shouldInjectProxyHeaders = true; + if (runtimeEnv[backendId]["NG_TRUST_PROXY_HEADERS"]) { + const userProxyHeadersStr = runtimeEnv[backendId]["NG_TRUST_PROXY_HEADERS"].value; + const userProxyHeaders = userProxyHeadersStr + ? userProxyHeadersStr + .split(",") + .map((h) => h.trim().toLowerCase()) + .filter(Boolean) + : []; + + const isSubset = + userProxyHeaders.length > 0 && userProxyHeaders.every((h) => allowedProxyHeaders.includes(h)); + + if (isSubset) { + shouldInjectProxyHeaders = false; + } else { + throw new FirebaseError( + `User-defined RUNTIME environment variable NG_TRUST_PROXY_HEADERS contains invalid headers. Allowed values: ${allowedProxyHeadersValue}`, + ); + } + } + + if (shouldInjectProxyHeaders) { + runtimeEnv[backendId]["NG_TRUST_PROXY_HEADERS"] = { + value: allowedProxyHeadersValue, + availability: ["RUNTIME"], + }; + } + + // 2. Inject NG_ALLOWED_HOSTS + if (!runtimeEnv[backendId]["NG_ALLOWED_HOSTS"]) { + const projectNumber = await getProjectNumber({ project: projectId }); + const sharedDomain = "hosted.app"; + // TODO: This is missing the custom domains retrieved dynamically by the control plane during Cloud Build creation. + const defaultHosts = [ + `${backendId}-${projectNumber}.${location}.run.app`, + `${backendId}--${projectId}.${location}.${sharedDomain}`, + `${backendId}--${projectId}.web.app`, + `${backendId}--${projectId}.firebaseapp.com`, + ]; + + runtimeEnv[backendId]["NG_ALLOWED_HOSTS"] = { + value: defaultHosts.join(","), + availability: ["RUNTIME"], + }; + } +}