diff --git a/package-lock.json b/package-lock.json index 98a5327341..b43ba11ac7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23629,9 +23629,9 @@ } }, "node_modules/zod": { - "version": "3.25.75", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.75.tgz", - "integrity": "sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -23691,7 +23691,8 @@ "typescript": "^5.0.0", "uuid": "^11.0.0", "yaml": "^2.8.0", - "yargs": "^17.6.0" + "yargs": "^17.6.0", + "zod": "^3.25.76" }, "bin": { "netlify-build": "bin.js" diff --git a/packages/build/package.json b/packages/build/package.json index f7ecf3eef8..9ed6935ff4 100644 --- a/packages/build/package.json +++ b/packages/build/package.json @@ -115,7 +115,8 @@ "typescript": "^5.0.0", "uuid": "^11.0.0", "yaml": "^2.8.0", - "yargs": "^17.6.0" + "yargs": "^17.6.0", + "zod": "^3.25.76" }, "devDependencies": { "@netlify/nock-udp": "^5.0.1", diff --git a/packages/build/src/plugins_core/edge_functions/index.ts b/packages/build/src/plugins_core/edge_functions/index.ts index 46884c0959..e28bcbec21 100644 --- a/packages/build/src/plugins_core/edge_functions/index.ts +++ b/packages/build/src/plugins_core/edge_functions/index.ts @@ -8,7 +8,7 @@ import { Metric } from '../../core/report_metrics.js' import { log, reduceLogLines } from '../../log/logger.js' import { logFunctionsToBundle } from '../../log/messages/core_steps.js' import { - FRAMEWORKS_API_EDGE_FUNCTIONS_ENDPOINT, + FRAMEWORKS_API_EDGE_FUNCTIONS_PATH, FRAMEWORKS_API_EDGE_FUNCTIONS_IMPORT_MAP, } from '../../utils/frameworks_api.js' @@ -52,7 +52,7 @@ const coreStep = async function ({ const internalSrcPath = resolve(buildDir, internalSrcDirectory) const distImportMapPath = join(dirname(internalSrcPath), IMPORT_MAP_FILENAME) const srcPath = srcDirectory ? resolve(buildDir, srcDirectory) : undefined - const frameworksAPISrcPath = resolve(buildDir, packagePath || '', FRAMEWORKS_API_EDGE_FUNCTIONS_ENDPOINT) + const frameworksAPISrcPath = resolve(buildDir, packagePath || '', FRAMEWORKS_API_EDGE_FUNCTIONS_PATH) const generatedFunctionPaths = [internalSrcPath] if (await pathExists(frameworksAPISrcPath)) { @@ -62,7 +62,7 @@ const coreStep = async function ({ const frameworkImportMap = resolve( buildDir, packagePath || '', - FRAMEWORKS_API_EDGE_FUNCTIONS_ENDPOINT, + FRAMEWORKS_API_EDGE_FUNCTIONS_PATH, FRAMEWORKS_API_EDGE_FUNCTIONS_IMPORT_MAP, ) @@ -170,7 +170,7 @@ const hasEdgeFunctionsDirectories = async function ({ return true } - const frameworkFunctionsSrc = resolve(buildDir, packagePath || '', FRAMEWORKS_API_EDGE_FUNCTIONS_ENDPOINT) + const frameworkFunctionsSrc = resolve(buildDir, packagePath || '', FRAMEWORKS_API_EDGE_FUNCTIONS_PATH) return await pathExists(frameworkFunctionsSrc) } diff --git a/packages/build/src/plugins_core/frameworks_api/index.ts b/packages/build/src/plugins_core/frameworks_api/index.ts index eead513884..b4ae6a2f11 100644 --- a/packages/build/src/plugins_core/frameworks_api/index.ts +++ b/packages/build/src/plugins_core/frameworks_api/index.ts @@ -1,9 +1,14 @@ +import { promises as fs } from 'node:fs' +import { dirname, resolve } from 'node:path' + import { mergeConfigs } from '@netlify/config' import type { NetlifyConfig } from '../../index.js' import { getConfigMutations } from '../../plugins/child/diff.js' +import { DEPLOY_CONFIG_DIST_PATH, FRAMEWORKS_API_SKEW_PROTECTION_PATH } from '../../utils/frameworks_api.js' import { CoreStep, CoreStepFunction } from '../types.js' +import { loadSkewProtectionConfig } from './skew_protection.js' import { filterConfig, loadConfigFile } from './util.js' // The properties that can be set using this API. Each element represents a @@ -26,6 +31,30 @@ const ALLOWED_PROPERTIES = [ // a special notation where `redirects!` represents "forced redirects", etc. const OVERRIDE_PROPERTIES = new Set(['redirects!']) +// Looks for a skew protection configuration file. If found, the file is loaded +// and validated against the schema, throwing a build error if validation +// fails. If valid, the contents are written to the deploy config file. +const handleSkewProtection = async (buildDir: string, packagePath?: string) => { + const inputPath = resolve(buildDir, packagePath ?? '', FRAMEWORKS_API_SKEW_PROTECTION_PATH) + const outputPath = resolve(buildDir, packagePath ?? '', DEPLOY_CONFIG_DIST_PATH) + + const skewProtectionConfig = await loadSkewProtectionConfig(inputPath) + if (!skewProtectionConfig) { + return + } + + const deployConfig = { + skew_protection: skewProtectionConfig, + } + + try { + await fs.mkdir(dirname(outputPath), { recursive: true }) + await fs.writeFile(outputPath, JSON.stringify(deployConfig)) + } catch (error) { + throw new Error('Failed to process skew protection configuration', { cause: error }) + } +} + const coreStep: CoreStepFunction = async function ({ buildDir, netlifyConfig, @@ -34,6 +63,7 @@ const coreStep: CoreStepFunction = async function ({ // no-op }, }) { + await handleSkewProtection(buildDir, packagePath) let config: Partial | undefined try { diff --git a/packages/build/src/plugins_core/frameworks_api/skew_protection.ts b/packages/build/src/plugins_core/frameworks_api/skew_protection.ts new file mode 100644 index 0000000000..09003a1275 --- /dev/null +++ b/packages/build/src/plugins_core/frameworks_api/skew_protection.ts @@ -0,0 +1,54 @@ +import { promises as fs } from 'node:fs' + +import { z } from 'zod' + +const deployIDSourceTypeSchema = z.enum(['cookie', 'header', 'query']) + +const deployIDSourceSchema = z.object({ + type: deployIDSourceTypeSchema, + name: z.string(), +}) + +const skewProtectionConfigSchema = z.object({ + patterns: z.array(z.string()), + sources: z.array(deployIDSourceSchema), +}) + +export type SkewProtectionConfig = z.infer +export type DeployIDSource = z.infer +export type DeployIDSourceType = z.infer + +const validateSkewProtectionConfig = (input: unknown): SkewProtectionConfig => { + const { data, error, success } = skewProtectionConfigSchema.safeParse(input) + + if (success) { + return data + } + + throw new Error(`Invalid skew protection configuration:\n\n${formatSchemaError(error)}`) +} + +export const loadSkewProtectionConfig = async (configPath: string) => { + let parsedData: unknown + + try { + const data = await fs.readFile(configPath, 'utf8') + + parsedData = JSON.parse(data) + } catch (error) { + // If the file doesn't exist, this is a non-error. + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return + } + + throw new Error('Invalid skew protection configuration', { cause: error }) + } + + return validateSkewProtectionConfig(parsedData) +} + +const formatSchemaError = (error: z.ZodError) => { + const lines = error.issues.map((issue) => `- ${issue.path.join('.')}: ${issue.message}`) + + return lines.join('\n') +} diff --git a/packages/build/src/plugins_core/frameworks_api/util.ts b/packages/build/src/plugins_core/frameworks_api/util.ts index 860c5d7a7d..d2bb69ba97 100644 --- a/packages/build/src/plugins_core/frameworks_api/util.ts +++ b/packages/build/src/plugins_core/frameworks_api/util.ts @@ -4,11 +4,11 @@ import { resolve } from 'path' import isPlainObject from 'is-plain-obj' import type { NetlifyConfig } from '../../index.js' -import { FRAMEWORKS_API_CONFIG_ENDPOINT } from '../../utils/frameworks_api.js' +import { FRAMEWORKS_API_CONFIG_PATH } from '../../utils/frameworks_api.js' import { SystemLogger } from '../types.js' export const loadConfigFile = async (buildDir: string, packagePath?: string) => { - const configPath = resolve(buildDir, packagePath ?? '', FRAMEWORKS_API_CONFIG_ENDPOINT) + const configPath = resolve(buildDir, packagePath ?? '', FRAMEWORKS_API_CONFIG_PATH) try { const data = await fs.readFile(configPath, 'utf8') diff --git a/packages/build/src/plugins_core/functions/index.ts b/packages/build/src/plugins_core/functions/index.ts index c502d0c5af..34f80ebe6f 100644 --- a/packages/build/src/plugins_core/functions/index.ts +++ b/packages/build/src/plugins_core/functions/index.ts @@ -7,7 +7,7 @@ import { addErrorInfo } from '../../error/info.js' import { log } from '../../log/logger.js' import { type GeneratedFunction, getGeneratedFunctions } from '../../steps/return_values.js' import { logBundleResults, logFunctionsNonExistingDir, logFunctionsToBundle } from '../../log/messages/core_steps.js' -import { FRAMEWORKS_API_FUNCTIONS_ENDPOINT } from '../../utils/frameworks_api.js' +import { FRAMEWORKS_API_FUNCTIONS_PATH } from '../../utils/frameworks_api.js' import { getZipError } from './error.js' import { getUserAndInternalFunctions, validateFunctionsSrc } from './utils.js' @@ -147,7 +147,7 @@ const coreStep = async function ({ const functionsDist = resolve(buildDir, relativeFunctionsDist) const internalFunctionsSrc = resolve(buildDir, relativeInternalFunctionsSrc) const internalFunctionsSrcExists = await pathExists(internalFunctionsSrc) - const frameworkFunctionsSrc = resolve(buildDir, packagePath || '', FRAMEWORKS_API_FUNCTIONS_ENDPOINT) + const frameworkFunctionsSrc = resolve(buildDir, packagePath || '', FRAMEWORKS_API_FUNCTIONS_PATH) const frameworkFunctionsSrcExists = await pathExists(frameworkFunctionsSrc) const functionsSrcExists = await validateFunctionsSrc({ functionsSrc, relativeFunctionsSrc }) const [userFunctions = [], internalFunctions = [], frameworkFunctions = []] = await getUserAndInternalFunctions({ @@ -240,7 +240,7 @@ const hasFunctionsDirectories = async function ({ return true } - const frameworkFunctionsSrc = resolve(buildDir, packagePath || '', FRAMEWORKS_API_FUNCTIONS_ENDPOINT) + const frameworkFunctionsSrc = resolve(buildDir, packagePath || '', FRAMEWORKS_API_FUNCTIONS_PATH) if (await pathExists(frameworkFunctionsSrc)) { return true diff --git a/packages/build/src/plugins_core/pre_cleanup/index.ts b/packages/build/src/plugins_core/pre_cleanup/index.ts index 3fa1035c7f..61d5ed3769 100644 --- a/packages/build/src/plugins_core/pre_cleanup/index.ts +++ b/packages/build/src/plugins_core/pre_cleanup/index.ts @@ -2,11 +2,11 @@ import { rm } from 'node:fs/promises' import { resolve } from 'node:path' import { getBlobsDirs } from '../../utils/blobs.js' -import { FRAMEWORKS_API_ENDPOINT } from '../../utils/frameworks_api.js' +import { FRAMEWORKS_API_PATH } from '../../utils/frameworks_api.js' import { CoreStep, CoreStepFunction } from '../types.js' const coreStep: CoreStepFunction = async ({ buildDir, packagePath }) => { - const dirs = [...getBlobsDirs(buildDir, packagePath), resolve(buildDir, packagePath || '', FRAMEWORKS_API_ENDPOINT)] + const dirs = [...getBlobsDirs(buildDir, packagePath), resolve(buildDir, packagePath || '', FRAMEWORKS_API_PATH)] try { await Promise.all(dirs.map((dir) => rm(dir, { recursive: true, force: true }))) diff --git a/packages/build/src/utils/blobs.ts b/packages/build/src/utils/blobs.ts index 5b9a38f017..73a0494fcb 100644 --- a/packages/build/src/utils/blobs.ts +++ b/packages/build/src/utils/blobs.ts @@ -5,7 +5,7 @@ import { fdir } from 'fdir' import { DEFAULT_API_HOST } from '../core/normalize_flags.js' -import { FRAMEWORKS_API_BLOBS_ENDPOINT } from './frameworks_api.js' +import { FRAMEWORKS_API_BLOBS_PATH } from './frameworks_api.js' const LEGACY_BLOBS_PATH = '.netlify/blobs/deploy' const DEPLOY_CONFIG_BLOBS_PATH = '.netlify/deploy/v1/blobs/deploy' @@ -60,7 +60,7 @@ export const getBlobsEnvironmentContext = ({ */ export const scanForBlobs = async function (buildDir: string, packagePath?: string) { // We start by looking for files using the Frameworks API. - const frameworkBlobsDir = path.resolve(buildDir, packagePath || '', FRAMEWORKS_API_BLOBS_ENDPOINT, 'deploy') + const frameworkBlobsDir = path.resolve(buildDir, packagePath || '', FRAMEWORKS_API_BLOBS_PATH, 'deploy') const frameworkBlobsDirScan = await new fdir().onlyCounts().crawl(frameworkBlobsDir).withPromise() if (frameworkBlobsDirScan.files > 0) { diff --git a/packages/build/src/utils/frameworks_api.ts b/packages/build/src/utils/frameworks_api.ts index af930bc79a..2da37a4466 100644 --- a/packages/build/src/utils/frameworks_api.ts +++ b/packages/build/src/utils/frameworks_api.ts @@ -2,12 +2,15 @@ import { basename, dirname, resolve, sep } from 'node:path' import { fdir } from 'fdir' -export const FRAMEWORKS_API_ENDPOINT = '.netlify/v1' -export const FRAMEWORKS_API_BLOBS_ENDPOINT = `${FRAMEWORKS_API_ENDPOINT}/blobs` -export const FRAMEWORKS_API_CONFIG_ENDPOINT = `${FRAMEWORKS_API_ENDPOINT}/config.json` -export const FRAMEWORKS_API_EDGE_FUNCTIONS_ENDPOINT = `${FRAMEWORKS_API_ENDPOINT}/edge-functions` +export const FRAMEWORKS_API_PATH = '.netlify/v1' +export const FRAMEWORKS_API_BLOBS_PATH = `${FRAMEWORKS_API_PATH}/blobs` +export const FRAMEWORKS_API_CONFIG_PATH = `${FRAMEWORKS_API_PATH}/config.json` +export const FRAMEWORKS_API_EDGE_FUNCTIONS_PATH = `${FRAMEWORKS_API_PATH}/edge-functions` export const FRAMEWORKS_API_EDGE_FUNCTIONS_IMPORT_MAP = 'import_map.json' -export const FRAMEWORKS_API_FUNCTIONS_ENDPOINT = `${FRAMEWORKS_API_ENDPOINT}/functions` +export const FRAMEWORKS_API_FUNCTIONS_PATH = `${FRAMEWORKS_API_PATH}/functions` +export const FRAMEWORKS_API_SKEW_PROTECTION_PATH = `${FRAMEWORKS_API_PATH}/skew-protection.json` + +export const DEPLOY_CONFIG_DIST_PATH = '.netlify/deploy-config/deploy-config.json' type DirectoryTreeFiles = Map diff --git a/packages/build/tests/frameworks_api/fixtures/skew_protection_invalid/build.mjs b/packages/build/tests/frameworks_api/fixtures/skew_protection_invalid/build.mjs new file mode 100644 index 0000000000..1a0265a4d6 --- /dev/null +++ b/packages/build/tests/frameworks_api/fixtures/skew_protection_invalid/build.mjs @@ -0,0 +1,8 @@ +import { mkdir, writeFile } from 'node:fs/promises' + +await mkdir('.netlify/v1', { recursive: true }) + +await writeFile('.netlify/v1/skew-protection.json', JSON.stringify({ + patterns: ["api"], + sources: [{ type: "invalid_type", name: "test" }] +})) \ No newline at end of file diff --git a/packages/build/tests/frameworks_api/fixtures/skew_protection_invalid/netlify.toml b/packages/build/tests/frameworks_api/fixtures/skew_protection_invalid/netlify.toml new file mode 100644 index 0000000000..5bc55bdb75 --- /dev/null +++ b/packages/build/tests/frameworks_api/fixtures/skew_protection_invalid/netlify.toml @@ -0,0 +1,2 @@ +[build] +command = "node build.mjs" \ No newline at end of file diff --git a/packages/build/tests/frameworks_api/fixtures/skew_protection_malformed/build.mjs b/packages/build/tests/frameworks_api/fixtures/skew_protection_malformed/build.mjs new file mode 100644 index 0000000000..2a01946465 --- /dev/null +++ b/packages/build/tests/frameworks_api/fixtures/skew_protection_malformed/build.mjs @@ -0,0 +1,5 @@ +import { mkdir, writeFile } from 'node:fs/promises' + +await mkdir('.netlify/v1', { recursive: true }) + +await writeFile('.netlify/v1/skew-protection.json', `{"patterns":`) \ No newline at end of file diff --git a/packages/build/tests/frameworks_api/fixtures/skew_protection_malformed/netlify.toml b/packages/build/tests/frameworks_api/fixtures/skew_protection_malformed/netlify.toml new file mode 100644 index 0000000000..5bc55bdb75 --- /dev/null +++ b/packages/build/tests/frameworks_api/fixtures/skew_protection_malformed/netlify.toml @@ -0,0 +1,2 @@ +[build] +command = "node build.mjs" \ No newline at end of file diff --git a/packages/build/tests/frameworks_api/fixtures/skew_protection_missing/build.mjs b/packages/build/tests/frameworks_api/fixtures/skew_protection_missing/build.mjs new file mode 100644 index 0000000000..d51b631677 --- /dev/null +++ b/packages/build/tests/frameworks_api/fixtures/skew_protection_missing/build.mjs @@ -0,0 +1 @@ +// No skew protection file created \ No newline at end of file diff --git a/packages/build/tests/frameworks_api/fixtures/skew_protection_missing/netlify.toml b/packages/build/tests/frameworks_api/fixtures/skew_protection_missing/netlify.toml new file mode 100644 index 0000000000..5bc55bdb75 --- /dev/null +++ b/packages/build/tests/frameworks_api/fixtures/skew_protection_missing/netlify.toml @@ -0,0 +1,2 @@ +[build] +command = "node build.mjs" \ No newline at end of file diff --git a/packages/build/tests/frameworks_api/fixtures/skew_protection_valid/build.mjs b/packages/build/tests/frameworks_api/fixtures/skew_protection_valid/build.mjs new file mode 100644 index 0000000000..e1da750bb7 --- /dev/null +++ b/packages/build/tests/frameworks_api/fixtures/skew_protection_valid/build.mjs @@ -0,0 +1,12 @@ +import { mkdir, writeFile } from 'node:fs/promises' + +await mkdir('.netlify/v1', { recursive: true }) + +await writeFile('.netlify/v1/skew-protection.json', JSON.stringify({ + patterns: ["/api/*", "/dashboard/*"], + sources: [ + { type: "cookie", name: "nf_deploy_id" }, + { type: "header", name: "x-nf-deploy-id" }, + { type: "query", name: "deploy_id" } + ] +})) \ No newline at end of file diff --git a/packages/build/tests/frameworks_api/fixtures/skew_protection_valid/netlify.toml b/packages/build/tests/frameworks_api/fixtures/skew_protection_valid/netlify.toml new file mode 100644 index 0000000000..5bc55bdb75 --- /dev/null +++ b/packages/build/tests/frameworks_api/fixtures/skew_protection_valid/netlify.toml @@ -0,0 +1,2 @@ +[build] +command = "node build.mjs" \ No newline at end of file diff --git a/packages/build/tests/frameworks_api/tests.js b/packages/build/tests/frameworks_api/tests.js index 3a3108001b..09b27b61be 100644 --- a/packages/build/tests/frameworks_api/tests.js +++ b/packages/build/tests/frameworks_api/tests.js @@ -151,3 +151,56 @@ test('Removes any leftover files from a previous build', async (t) => { remote_images: ['domain1.from-toml.netlify', 'domain2.from-toml.netlify'], }) }) + +test('Throws an error if the skew protection configuration file is invalid', async (t) => { + const { output, success } = await new Fixture('./fixtures/skew_protection_invalid').runWithBuildAndIntrospect() + t.false(success) + t.true(output.includes('Invalid skew protection configuration')) + t.true( + output.includes( + `sources.0.type: Invalid enum value. Expected 'cookie' | 'header' | 'query', received 'invalid_type'`, + ), + ) +}) + +test('Throws an error if the skew protection configuration file is malformed', async (t) => { + const { output, success } = await new Fixture('./fixtures/skew_protection_malformed').runWithBuildAndIntrospect() + t.false(success) + t.true(output.includes('Invalid skew protection configuration')) +}) + +test('Does not create dist file when skew protection file is missing', async (t) => { + const fixture = new Fixture('./fixtures/skew_protection_missing') + const { success } = await fixture.runWithBuildAndIntrospect() + const distPath = resolve(fixture.repositoryRoot, '.netlify/deploy-config/deploy-config.json') + t.true(success) + + try { + await fs.access(distPath) + t.fail('Dist file should not exist when skew protection file is missing') + } catch (error) { + t.is(error.code, 'ENOENT') + } +}) + +test('Creates dist file when valid skew protection configuration is provided', async (t) => { + const fixture = new Fixture('./fixtures/skew_protection_valid') + const { success } = await fixture.runWithBuildAndIntrospect() + const distPath = resolve(fixture.repositoryRoot, '.netlify/deploy-config/deploy-config.json') + + t.true(success) + + const distContent = await fs.readFile(distPath, 'utf8') + const config = JSON.parse(distContent) + + t.deepEqual(config, { + skew_protection: { + patterns: ['/api/*', '/dashboard/*'], + sources: [ + { type: 'cookie', name: 'nf_deploy_id' }, + { type: 'header', name: 'x-nf-deploy-id' }, + { type: 'query', name: 'deploy_id' }, + ], + }, + }) +})