Skip to content

Commit 47fb69f

Browse files
committed
feat: support skew protection
1 parent ece4338 commit 47fb69f

File tree

4 files changed

+450
-1
lines changed

4 files changed

+450
-1
lines changed

src/build/plugin-context.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,11 @@ export class PluginContext {
207207
return join(this.edgeFunctionsDir, EDGE_HANDLER_NAME)
208208
}
209209

210+
/** Absolute path to the skew protection config */
211+
get skewProtectionConfigPath(): string {
212+
return this.resolveFromPackagePath('.netlify/v1/skew-protection.json')
213+
}
214+
210215
constructor(options: NetlifyPluginOptions) {
211216
this.constants = options.constants
212217
this.featureFlags = options.featureFlags

src/build/skew-protection.test.ts

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
import { mkdir, writeFile } from 'node:fs/promises'
2+
import { dirname } from 'node:path'
3+
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
import type { PluginContext } from './plugin-context.js'
7+
import { setSkewProtection, shouldEnableSkewProtection } from './skew-protection.js'
8+
9+
// Mock fs promises
10+
vi.mock('node:fs/promises', () => ({
11+
mkdir: vi.fn(),
12+
writeFile: vi.fn(),
13+
}))
14+
15+
// Mock path
16+
vi.mock('node:path', () => ({
17+
dirname: vi.fn(),
18+
}))
19+
20+
describe('shouldEnableSkewProtection', () => {
21+
let mockCtx: PluginContext
22+
let originalEnv: NodeJS.ProcessEnv
23+
24+
beforeEach(() => {
25+
// Save original env
26+
originalEnv = { ...process.env }
27+
28+
// Reset env vars
29+
delete process.env.NETLIFY_NEXT_SKEW_PROTECTION
30+
// Set valid DEPLOY_ID by default
31+
process.env.DEPLOY_ID = 'test-deploy-id'
32+
33+
mockCtx = {
34+
featureFlags: {},
35+
constants: {
36+
IS_LOCAL: false,
37+
},
38+
} as PluginContext
39+
40+
vi.clearAllMocks()
41+
})
42+
43+
afterEach(() => {
44+
// Restore original env
45+
process.env = originalEnv
46+
})
47+
48+
describe('default behavior', () => {
49+
it('should return disabled by default', () => {
50+
const result = shouldEnableSkewProtection(mockCtx)
51+
52+
expect(result).toEqual({
53+
enabled: false,
54+
enabledOrDisabledReason: 'off-default',
55+
})
56+
})
57+
})
58+
59+
describe('environment variable opt-in', () => {
60+
it('should enable when NETLIFY_NEXT_SKEW_PROTECTION is "true"', () => {
61+
process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'true'
62+
63+
const result = shouldEnableSkewProtection(mockCtx)
64+
65+
expect(result).toEqual({
66+
enabled: true,
67+
enabledOrDisabledReason: 'on-env-var',
68+
})
69+
})
70+
71+
it('should enable when NETLIFY_NEXT_SKEW_PROTECTION is "1"', () => {
72+
process.env.NETLIFY_NEXT_SKEW_PROTECTION = '1'
73+
74+
const result = shouldEnableSkewProtection(mockCtx)
75+
76+
expect(result).toEqual({
77+
enabled: true,
78+
enabledOrDisabledReason: 'on-env-var',
79+
})
80+
})
81+
})
82+
83+
describe('environment variable opt-out', () => {
84+
it('should disable when NETLIFY_NEXT_SKEW_PROTECTION is "false"', () => {
85+
process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'false'
86+
87+
const result = shouldEnableSkewProtection(mockCtx)
88+
89+
expect(result).toEqual({
90+
enabled: false,
91+
enabledOrDisabledReason: 'off-env-var',
92+
})
93+
})
94+
95+
it('should disable when NETLIFY_NEXT_SKEW_PROTECTION is "0"', () => {
96+
process.env.NETLIFY_NEXT_SKEW_PROTECTION = '0'
97+
98+
const result = shouldEnableSkewProtection(mockCtx)
99+
100+
expect(result).toEqual({
101+
enabled: false,
102+
enabledOrDisabledReason: 'off-env-var',
103+
})
104+
})
105+
})
106+
107+
describe('feature flag opt-in', () => {
108+
it('should enable when feature flag is set', () => {
109+
mockCtx.featureFlags = { 'next-runtime-skew-protection': true }
110+
111+
const result = shouldEnableSkewProtection(mockCtx)
112+
113+
expect(result).toEqual({
114+
enabled: true,
115+
enabledOrDisabledReason: 'on-ff',
116+
})
117+
})
118+
119+
it('should not enable when feature flag is false', () => {
120+
mockCtx.featureFlags = { 'next-runtime-skew-protection': false }
121+
122+
const result = shouldEnableSkewProtection(mockCtx)
123+
124+
expect(result).toEqual({
125+
enabled: false,
126+
enabledOrDisabledReason: 'off-default',
127+
})
128+
})
129+
})
130+
131+
describe('DEPLOY_ID validation', () => {
132+
it('should disable when DEPLOY_ID is missing and not explicitly opted in', () => {
133+
mockCtx.featureFlags = { 'next-runtime-skew-protection': true }
134+
delete process.env.DEPLOY_ID
135+
136+
const result = shouldEnableSkewProtection(mockCtx)
137+
138+
expect(result).toEqual({
139+
enabled: false,
140+
enabledOrDisabledReason: 'off-no-valid-deploy-id',
141+
})
142+
})
143+
144+
it('should disable when DEPLOY_ID is "0" and not explicitly opted in', () => {
145+
mockCtx.featureFlags = { 'next-runtime-skew-protection': true }
146+
process.env.DEPLOY_ID = '0'
147+
148+
const result = shouldEnableSkewProtection(mockCtx)
149+
150+
expect(result).toEqual({
151+
enabled: false,
152+
enabledOrDisabledReason: 'off-no-valid-deploy-id',
153+
})
154+
})
155+
156+
it('should show specific reason when env var is set but DEPLOY_ID is invalid in local context', () => {
157+
process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'true'
158+
process.env.DEPLOY_ID = '0'
159+
mockCtx.constants.IS_LOCAL = true
160+
161+
const result = shouldEnableSkewProtection(mockCtx)
162+
163+
expect(result).toEqual({
164+
enabled: false,
165+
enabledOrDisabledReason: 'off-no-valid-deploy-id-env-var',
166+
})
167+
})
168+
})
169+
170+
describe('precedence', () => {
171+
it('should prioritize env var opt-out over feature flag', () => {
172+
process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'false'
173+
mockCtx.featureFlags = { 'next-runtime-skew-protection': true }
174+
175+
const result = shouldEnableSkewProtection(mockCtx)
176+
177+
expect(result).toEqual({
178+
enabled: false,
179+
enabledOrDisabledReason: 'off-env-var',
180+
})
181+
})
182+
183+
it('should prioritize env var opt-in over feature flag', () => {
184+
process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'true'
185+
mockCtx.featureFlags = { 'next-runtime-skew-protection': false }
186+
187+
const result = shouldEnableSkewProtection(mockCtx)
188+
189+
expect(result).toEqual({
190+
enabled: true,
191+
enabledOrDisabledReason: 'on-env-var',
192+
})
193+
})
194+
})
195+
})
196+
197+
describe('setSkewProtection', () => {
198+
let mockCtx: PluginContext
199+
let mockSpan: any
200+
let originalEnv: NodeJS.ProcessEnv
201+
let consoleSpy: any
202+
203+
beforeEach(() => {
204+
// Save original env
205+
originalEnv = { ...process.env }
206+
207+
// Reset env vars
208+
delete process.env.NETLIFY_NEXT_SKEW_PROTECTION
209+
delete process.env.NEXT_DEPLOYMENT_ID
210+
// Set valid DEPLOY_ID by default
211+
process.env.DEPLOY_ID = 'test-deploy-id'
212+
213+
mockCtx = {
214+
featureFlags: {},
215+
constants: {
216+
IS_LOCAL: false,
217+
},
218+
skewProtectionConfigPath: '/test/path/skew-protection.json',
219+
} as PluginContext
220+
221+
mockSpan = {
222+
setAttribute: vi.fn(),
223+
}
224+
225+
consoleSpy = {
226+
log: vi.spyOn(console, 'log').mockImplementation(() => {}),
227+
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
228+
}
229+
230+
vi.clearAllMocks()
231+
})
232+
233+
afterEach(() => {
234+
// Restore original env
235+
process.env = originalEnv
236+
consoleSpy.log.mockRestore()
237+
consoleSpy.warn.mockRestore()
238+
})
239+
240+
it('should set span attribute and return early when disabled', async () => {
241+
await setSkewProtection(mockCtx, mockSpan)
242+
243+
expect(mockSpan.setAttribute).toHaveBeenCalledWith('skewProtection', 'off-default')
244+
expect(mkdir).not.toHaveBeenCalled()
245+
expect(writeFile).not.toHaveBeenCalled()
246+
expect(consoleSpy.log).not.toHaveBeenCalled()
247+
expect(consoleSpy.warn).not.toHaveBeenCalled()
248+
})
249+
250+
it('should show warning when env var is set but no valid DEPLOY_ID', async () => {
251+
process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'true'
252+
process.env.DEPLOY_ID = '0'
253+
mockCtx.constants.IS_LOCAL = true
254+
255+
await setSkewProtection(mockCtx, mockSpan)
256+
257+
expect(mockSpan.setAttribute).toHaveBeenCalledWith(
258+
'skewProtection',
259+
'off-no-valid-deploy-id-env-var',
260+
)
261+
expect(consoleSpy.warn).toHaveBeenCalledWith(
262+
'NETLIFY_NEXT_SKEW_PROTECTION environment variable is set to true, but skew protection is currently unavailable for CLI deploys. Skew protection will not be enabled.',
263+
)
264+
expect(mkdir).not.toHaveBeenCalled()
265+
expect(writeFile).not.toHaveBeenCalled()
266+
})
267+
268+
it('should set up skew protection when enabled via env var', async () => {
269+
process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'true'
270+
271+
vi.mocked(dirname).mockReturnValue('/test/path')
272+
273+
await setSkewProtection(mockCtx, mockSpan)
274+
275+
expect(mockSpan.setAttribute).toHaveBeenCalledWith('skewProtection', 'on-env-var')
276+
expect(consoleSpy.log).toHaveBeenCalledWith(
277+
'Setting up Next.js Skew Protection due to NETLIFY_NEXT_SKEW_PROTECTION=true environment variable.',
278+
)
279+
expect(process.env.NEXT_DEPLOYMENT_ID).toBe('test-deploy-id')
280+
expect(mkdir).toHaveBeenCalledWith('/test/path', { recursive: true })
281+
expect(writeFile).toHaveBeenCalledWith(
282+
'/test/path/skew-protection.json',
283+
JSON.stringify(
284+
{
285+
patterns: ['.*'],
286+
sources: [
287+
{
288+
type: 'cookie',
289+
name: '__vdpl',
290+
},
291+
{
292+
type: 'header',
293+
name: 'X-Deployment-Id',
294+
},
295+
{
296+
type: 'query',
297+
name: 'dpl',
298+
},
299+
],
300+
},
301+
null,
302+
2,
303+
),
304+
)
305+
})
306+
307+
it('should set up skew protection when enabled via feature flag', async () => {
308+
mockCtx.featureFlags = { 'next-runtime-skew-protection': true }
309+
310+
vi.mocked(dirname).mockReturnValue('/test/path')
311+
312+
await setSkewProtection(mockCtx, mockSpan)
313+
314+
expect(mockSpan.setAttribute).toHaveBeenCalledWith('skewProtection', 'on-ff')
315+
expect(consoleSpy.log).toHaveBeenCalledWith('Setting up Next.js Skew Protection.')
316+
expect(process.env.NEXT_DEPLOYMENT_ID).toBe('test-deploy-id')
317+
expect(mkdir).toHaveBeenCalledWith('/test/path', { recursive: true })
318+
expect(writeFile).toHaveBeenCalledWith('/test/path/skew-protection.json', expect.any(String))
319+
})
320+
321+
it('should handle different env var values correctly', async () => {
322+
process.env.NETLIFY_NEXT_SKEW_PROTECTION = '1'
323+
324+
await setSkewProtection(mockCtx, mockSpan)
325+
326+
expect(consoleSpy.log).toHaveBeenCalledWith(
327+
'Setting up Next.js Skew Protection due to NETLIFY_NEXT_SKEW_PROTECTION=1 environment variable.',
328+
)
329+
})
330+
})

0 commit comments

Comments
 (0)