Skip to content

Commit 7ba5192

Browse files
committed
feat: support skew protection
1 parent ece4338 commit 7ba5192

File tree

4 files changed

+458
-1
lines changed

4 files changed

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

0 commit comments

Comments
 (0)