Skip to content

Commit 6cf3065

Browse files
feat: add skew protection to Frameworks API (#6601)
1 parent 969acf2 commit 6cf3065

File tree

19 files changed

+199
-23
lines changed

19 files changed

+199
-23
lines changed

package-lock.json

Lines changed: 5 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/build/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,8 @@
115115
"typescript": "^5.0.0",
116116
"uuid": "^11.0.0",
117117
"yaml": "^2.8.0",
118-
"yargs": "^17.6.0"
118+
"yargs": "^17.6.0",
119+
"zod": "^3.25.76"
119120
},
120121
"devDependencies": {
121122
"@netlify/nock-udp": "^5.0.1",

packages/build/src/plugins_core/edge_functions/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Metric } from '../../core/report_metrics.js'
88
import { log, reduceLogLines } from '../../log/logger.js'
99
import { logFunctionsToBundle } from '../../log/messages/core_steps.js'
1010
import {
11-
FRAMEWORKS_API_EDGE_FUNCTIONS_ENDPOINT,
11+
FRAMEWORKS_API_EDGE_FUNCTIONS_PATH,
1212
FRAMEWORKS_API_EDGE_FUNCTIONS_IMPORT_MAP,
1313
} from '../../utils/frameworks_api.js'
1414

@@ -52,7 +52,7 @@ const coreStep = async function ({
5252
const internalSrcPath = resolve(buildDir, internalSrcDirectory)
5353
const distImportMapPath = join(dirname(internalSrcPath), IMPORT_MAP_FILENAME)
5454
const srcPath = srcDirectory ? resolve(buildDir, srcDirectory) : undefined
55-
const frameworksAPISrcPath = resolve(buildDir, packagePath || '', FRAMEWORKS_API_EDGE_FUNCTIONS_ENDPOINT)
55+
const frameworksAPISrcPath = resolve(buildDir, packagePath || '', FRAMEWORKS_API_EDGE_FUNCTIONS_PATH)
5656
const generatedFunctionPaths = [internalSrcPath]
5757

5858
if (await pathExists(frameworksAPISrcPath)) {
@@ -62,7 +62,7 @@ const coreStep = async function ({
6262
const frameworkImportMap = resolve(
6363
buildDir,
6464
packagePath || '',
65-
FRAMEWORKS_API_EDGE_FUNCTIONS_ENDPOINT,
65+
FRAMEWORKS_API_EDGE_FUNCTIONS_PATH,
6666
FRAMEWORKS_API_EDGE_FUNCTIONS_IMPORT_MAP,
6767
)
6868

@@ -170,7 +170,7 @@ const hasEdgeFunctionsDirectories = async function ({
170170
return true
171171
}
172172

173-
const frameworkFunctionsSrc = resolve(buildDir, packagePath || '', FRAMEWORKS_API_EDGE_FUNCTIONS_ENDPOINT)
173+
const frameworkFunctionsSrc = resolve(buildDir, packagePath || '', FRAMEWORKS_API_EDGE_FUNCTIONS_PATH)
174174

175175
return await pathExists(frameworkFunctionsSrc)
176176
}

packages/build/src/plugins_core/frameworks_api/index.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
import { promises as fs } from 'node:fs'
2+
import { dirname, resolve } from 'node:path'
3+
14
import { mergeConfigs } from '@netlify/config'
25

36
import type { NetlifyConfig } from '../../index.js'
47
import { getConfigMutations } from '../../plugins/child/diff.js'
8+
import { DEPLOY_CONFIG_DIST_PATH, FRAMEWORKS_API_SKEW_PROTECTION_PATH } from '../../utils/frameworks_api.js'
59
import { CoreStep, CoreStepFunction } from '../types.js'
610

11+
import { loadSkewProtectionConfig } from './skew_protection.js'
712
import { filterConfig, loadConfigFile } from './util.js'
813

914
// The properties that can be set using this API. Each element represents a
@@ -26,6 +31,30 @@ const ALLOWED_PROPERTIES = [
2631
// a special notation where `redirects!` represents "forced redirects", etc.
2732
const OVERRIDE_PROPERTIES = new Set(['redirects!'])
2833

34+
// Looks for a skew protection configuration file. If found, the file is loaded
35+
// and validated against the schema, throwing a build error if validation
36+
// fails. If valid, the contents are written to the deploy config file.
37+
const handleSkewProtection = async (buildDir: string, packagePath?: string) => {
38+
const inputPath = resolve(buildDir, packagePath ?? '', FRAMEWORKS_API_SKEW_PROTECTION_PATH)
39+
const outputPath = resolve(buildDir, packagePath ?? '', DEPLOY_CONFIG_DIST_PATH)
40+
41+
const skewProtectionConfig = await loadSkewProtectionConfig(inputPath)
42+
if (!skewProtectionConfig) {
43+
return
44+
}
45+
46+
const deployConfig = {
47+
skew_protection: skewProtectionConfig,
48+
}
49+
50+
try {
51+
await fs.mkdir(dirname(outputPath), { recursive: true })
52+
await fs.writeFile(outputPath, JSON.stringify(deployConfig))
53+
} catch (error) {
54+
throw new Error('Failed to process skew protection configuration', { cause: error })
55+
}
56+
}
57+
2958
const coreStep: CoreStepFunction = async function ({
3059
buildDir,
3160
netlifyConfig,
@@ -34,6 +63,7 @@ const coreStep: CoreStepFunction = async function ({
3463
// no-op
3564
},
3665
}) {
66+
await handleSkewProtection(buildDir, packagePath)
3767
let config: Partial<NetlifyConfig> | undefined
3868

3969
try {
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { promises as fs } from 'node:fs'
2+
3+
import { z } from 'zod'
4+
5+
const deployIDSourceTypeSchema = z.enum(['cookie', 'header', 'query'])
6+
7+
const deployIDSourceSchema = z.object({
8+
type: deployIDSourceTypeSchema,
9+
name: z.string(),
10+
})
11+
12+
const skewProtectionConfigSchema = z.object({
13+
patterns: z.array(z.string()),
14+
sources: z.array(deployIDSourceSchema),
15+
})
16+
17+
export type SkewProtectionConfig = z.infer<typeof skewProtectionConfigSchema>
18+
export type DeployIDSource = z.infer<typeof deployIDSourceSchema>
19+
export type DeployIDSourceType = z.infer<typeof deployIDSourceTypeSchema>
20+
21+
const validateSkewProtectionConfig = (input: unknown): SkewProtectionConfig => {
22+
const { data, error, success } = skewProtectionConfigSchema.safeParse(input)
23+
24+
if (success) {
25+
return data
26+
}
27+
28+
throw new Error(`Invalid skew protection configuration:\n\n${formatSchemaError(error)}`)
29+
}
30+
31+
export const loadSkewProtectionConfig = async (configPath: string) => {
32+
let parsedData: unknown
33+
34+
try {
35+
const data = await fs.readFile(configPath, 'utf8')
36+
37+
parsedData = JSON.parse(data)
38+
} catch (error) {
39+
// If the file doesn't exist, this is a non-error.
40+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
41+
return
42+
}
43+
44+
throw new Error('Invalid skew protection configuration', { cause: error })
45+
}
46+
47+
return validateSkewProtectionConfig(parsedData)
48+
}
49+
50+
const formatSchemaError = (error: z.ZodError) => {
51+
const lines = error.issues.map((issue) => `- ${issue.path.join('.')}: ${issue.message}`)
52+
53+
return lines.join('\n')
54+
}

packages/build/src/plugins_core/frameworks_api/util.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import { resolve } from 'path'
44
import isPlainObject from 'is-plain-obj'
55

66
import type { NetlifyConfig } from '../../index.js'
7-
import { FRAMEWORKS_API_CONFIG_ENDPOINT } from '../../utils/frameworks_api.js'
7+
import { FRAMEWORKS_API_CONFIG_PATH } from '../../utils/frameworks_api.js'
88
import { SystemLogger } from '../types.js'
99

1010
export const loadConfigFile = async (buildDir: string, packagePath?: string) => {
11-
const configPath = resolve(buildDir, packagePath ?? '', FRAMEWORKS_API_CONFIG_ENDPOINT)
11+
const configPath = resolve(buildDir, packagePath ?? '', FRAMEWORKS_API_CONFIG_PATH)
1212

1313
try {
1414
const data = await fs.readFile(configPath, 'utf8')

packages/build/src/plugins_core/functions/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { addErrorInfo } from '../../error/info.js'
77
import { log } from '../../log/logger.js'
88
import { type GeneratedFunction, getGeneratedFunctions } from '../../steps/return_values.js'
99
import { logBundleResults, logFunctionsNonExistingDir, logFunctionsToBundle } from '../../log/messages/core_steps.js'
10-
import { FRAMEWORKS_API_FUNCTIONS_ENDPOINT } from '../../utils/frameworks_api.js'
10+
import { FRAMEWORKS_API_FUNCTIONS_PATH } from '../../utils/frameworks_api.js'
1111

1212
import { getZipError } from './error.js'
1313
import { getUserAndInternalFunctions, validateFunctionsSrc } from './utils.js'
@@ -147,7 +147,7 @@ const coreStep = async function ({
147147
const functionsDist = resolve(buildDir, relativeFunctionsDist)
148148
const internalFunctionsSrc = resolve(buildDir, relativeInternalFunctionsSrc)
149149
const internalFunctionsSrcExists = await pathExists(internalFunctionsSrc)
150-
const frameworkFunctionsSrc = resolve(buildDir, packagePath || '', FRAMEWORKS_API_FUNCTIONS_ENDPOINT)
150+
const frameworkFunctionsSrc = resolve(buildDir, packagePath || '', FRAMEWORKS_API_FUNCTIONS_PATH)
151151
const frameworkFunctionsSrcExists = await pathExists(frameworkFunctionsSrc)
152152
const functionsSrcExists = await validateFunctionsSrc({ functionsSrc, relativeFunctionsSrc })
153153
const [userFunctions = [], internalFunctions = [], frameworkFunctions = []] = await getUserAndInternalFunctions({
@@ -240,7 +240,7 @@ const hasFunctionsDirectories = async function ({
240240
return true
241241
}
242242

243-
const frameworkFunctionsSrc = resolve(buildDir, packagePath || '', FRAMEWORKS_API_FUNCTIONS_ENDPOINT)
243+
const frameworkFunctionsSrc = resolve(buildDir, packagePath || '', FRAMEWORKS_API_FUNCTIONS_PATH)
244244

245245
if (await pathExists(frameworkFunctionsSrc)) {
246246
return true

packages/build/src/plugins_core/pre_cleanup/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import { rm } from 'node:fs/promises'
22
import { resolve } from 'node:path'
33

44
import { getBlobsDirs } from '../../utils/blobs.js'
5-
import { FRAMEWORKS_API_ENDPOINT } from '../../utils/frameworks_api.js'
5+
import { FRAMEWORKS_API_PATH } from '../../utils/frameworks_api.js'
66
import { CoreStep, CoreStepFunction } from '../types.js'
77

88
const coreStep: CoreStepFunction = async ({ buildDir, packagePath }) => {
9-
const dirs = [...getBlobsDirs(buildDir, packagePath), resolve(buildDir, packagePath || '', FRAMEWORKS_API_ENDPOINT)]
9+
const dirs = [...getBlobsDirs(buildDir, packagePath), resolve(buildDir, packagePath || '', FRAMEWORKS_API_PATH)]
1010

1111
try {
1212
await Promise.all(dirs.map((dir) => rm(dir, { recursive: true, force: true })))

packages/build/src/utils/blobs.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { fdir } from 'fdir'
55

66
import { DEFAULT_API_HOST } from '../core/normalize_flags.js'
77

8-
import { FRAMEWORKS_API_BLOBS_ENDPOINT } from './frameworks_api.js'
8+
import { FRAMEWORKS_API_BLOBS_PATH } from './frameworks_api.js'
99

1010
const LEGACY_BLOBS_PATH = '.netlify/blobs/deploy'
1111
const DEPLOY_CONFIG_BLOBS_PATH = '.netlify/deploy/v1/blobs/deploy'
@@ -60,7 +60,7 @@ export const getBlobsEnvironmentContext = ({
6060
*/
6161
export const scanForBlobs = async function (buildDir: string, packagePath?: string) {
6262
// We start by looking for files using the Frameworks API.
63-
const frameworkBlobsDir = path.resolve(buildDir, packagePath || '', FRAMEWORKS_API_BLOBS_ENDPOINT, 'deploy')
63+
const frameworkBlobsDir = path.resolve(buildDir, packagePath || '', FRAMEWORKS_API_BLOBS_PATH, 'deploy')
6464
const frameworkBlobsDirScan = await new fdir().onlyCounts().crawl(frameworkBlobsDir).withPromise()
6565

6666
if (frameworkBlobsDirScan.files > 0) {

packages/build/src/utils/frameworks_api.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@ import { basename, dirname, resolve, sep } from 'node:path'
22

33
import { fdir } from 'fdir'
44

5-
export const FRAMEWORKS_API_ENDPOINT = '.netlify/v1'
6-
export const FRAMEWORKS_API_BLOBS_ENDPOINT = `${FRAMEWORKS_API_ENDPOINT}/blobs`
7-
export const FRAMEWORKS_API_CONFIG_ENDPOINT = `${FRAMEWORKS_API_ENDPOINT}/config.json`
8-
export const FRAMEWORKS_API_EDGE_FUNCTIONS_ENDPOINT = `${FRAMEWORKS_API_ENDPOINT}/edge-functions`
5+
export const FRAMEWORKS_API_PATH = '.netlify/v1'
6+
export const FRAMEWORKS_API_BLOBS_PATH = `${FRAMEWORKS_API_PATH}/blobs`
7+
export const FRAMEWORKS_API_CONFIG_PATH = `${FRAMEWORKS_API_PATH}/config.json`
8+
export const FRAMEWORKS_API_EDGE_FUNCTIONS_PATH = `${FRAMEWORKS_API_PATH}/edge-functions`
99
export const FRAMEWORKS_API_EDGE_FUNCTIONS_IMPORT_MAP = 'import_map.json'
10-
export const FRAMEWORKS_API_FUNCTIONS_ENDPOINT = `${FRAMEWORKS_API_ENDPOINT}/functions`
10+
export const FRAMEWORKS_API_FUNCTIONS_PATH = `${FRAMEWORKS_API_PATH}/functions`
11+
export const FRAMEWORKS_API_SKEW_PROTECTION_PATH = `${FRAMEWORKS_API_PATH}/skew-protection.json`
12+
13+
export const DEPLOY_CONFIG_DIST_PATH = '.netlify/deploy-config/deploy-config.json'
1114

1215
type DirectoryTreeFiles = Map<string, string[]>
1316

0 commit comments

Comments
 (0)