Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
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 { EDGE_REDIRECTS_DIST_PATH, FRAMEWORKS_API_SKEW_PROTECTION_ENDPOINT } 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_ENDPOINT)
const outputPath = resolve(buildDir, packagePath ?? '', EDGE_REDIRECTS_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,45 @@
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 = (data: unknown): SkewProtectionConfig => {
try {
return skewProtectionConfigSchema.parse(data)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we could avoid the try-catch and conditional by using safeParse instead: https://zod.dev/basics?id=handling-errors

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a4f84c0.

} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Invalid skew protection configuration: ${error.message}`)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we extend the assertion on the test for this to ensure the message contains actionable information? I think we actually need to read .errors for that: https://zod.dev/basics?id=handling-errors — otherwise we just get a generic validation error(?). Either way, test coverage would be valuable here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a4f84c0.

}

throw error
}
}

export const loadSkewProtectionConfig = async (configPath: string) => {
try {
const data = await fs.readFile(configPath, 'utf8')
const config = validateSkewProtectionConfig(JSON.parse(data))

return config
} catch (err) {
// If the file doesn't exist, this is a non-error.
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
throw err
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are four cases here:

  1. exists + is json + valid
  2. exists + is json + invalid
  3. exists + is not json
  4. does not exist

We're testing 1, 2, and 4. Could we add a test for 3?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a4f84c0.

}
}
3 changes: 3 additions & 0 deletions packages/build/src/utils/frameworks_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export const FRAMEWORKS_API_CONFIG_ENDPOINT = `${FRAMEWORKS_API_ENDPOINT}/config
export const FRAMEWORKS_API_EDGE_FUNCTIONS_ENDPOINT = `${FRAMEWORKS_API_ENDPOINT}/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_SKEW_PROTECTION_ENDPOINT = `${FRAMEWORKS_API_ENDPOINT}/skew-protection.json`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this is existing naming, but I find endpoint to be a misleading term here (especially since many of these do have very similar URL endpoints). how about "path"?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 687a92c.


export const EDGE_REDIRECTS_DIST_PATH = '.netlify/deploy-config/edge-redirects.json'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't found any trace of this in Linear, Notion, or Slack, but it seems to have been around for years in nfserver? Is this dead code we're reviving from the partially implemented and partially reverted edge redirects project from 2023...?

In any case, I'm not quite following what Skew Protection has to do with edge redirects. Would you mind explaining?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in cadd32d.


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 @@
// 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"
43 changes: 43 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,46 @@ 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'))
})

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/edge-redirects.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/edge-redirects.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