Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
9 changes: 5 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/build/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 4 additions & 4 deletions packages/build/src/plugins_core/edge_functions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)) {
Expand All @@ -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,
)

Expand Down Expand Up @@ -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)
}
Expand Down
30 changes: 30 additions & 0 deletions packages/build/src/plugins_core/frameworks_api/index.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 edge redirects 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 edgeRedirects = {
skew_protection: skewProtectionConfig,
}

try {
await fs.mkdir(dirname(outputPath), { recursive: true })
await fs.writeFile(outputPath, JSON.stringify(edgeRedirects))
} catch (error) {
throw new Error('Failed to process skew protection configuration', { cause: error })
}
}

const coreStep: CoreStepFunction = async function ({
buildDir,
netlifyConfig,
Expand All @@ -34,6 +63,7 @@ const coreStep: CoreStepFunction = async function ({
// no-op
},
}) {
await handleSkewProtection(buildDir, packagePath)
let config: Partial<NetlifyConfig> | undefined

try {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof skewProtectionConfigSchema>
export type DeployIDSource = z.infer<typeof deployIDSourceSchema>
export type DeployIDSourceType = z.infer<typeof deployIDSourceTypeSchema>

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')
}
4 changes: 2 additions & 2 deletions packages/build/src/plugins_core/frameworks_api/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
6 changes: 3 additions & 3 deletions packages/build/src/plugins_core/functions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/build/src/plugins_core/pre_cleanup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })))
Expand Down
4 changes: 2 additions & 2 deletions packages/build/src/utils/blobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand Down
13 changes: 8 additions & 5 deletions packages/build/src/utils/frameworks_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]>

Expand Down
Original file line number Diff line number Diff line change
@@ -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" }]
}))
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[build]
command = "node build.mjs"
Original file line number Diff line number Diff line change
@@ -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":`)
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[build]
command = "node build.mjs"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// No skew protection file created
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[build]
command = "node build.mjs"
Original file line number Diff line number Diff line change
@@ -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" }
]
}))
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[build]
command = "node build.mjs"
53 changes: 53 additions & 0 deletions packages/build/tests/frameworks_api/tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
},
})
})
Loading