@appwarden/middleware 3.14.1 → 3.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/{chunk-2FSRHIPY.js → chunk-2ZKE4AI4.js} +50 -2
- package/{chunk-JJOX4UAG.js → chunk-IAOONP5L.js} +5 -4
- package/{chunk-PCWFMNHW.js → chunk-L4UHIU77.js} +17 -4
- package/{chunk-7BZFEX4Z.js → chunk-LRDZKQI3.js} +1 -1
- package/{chunk-LDKC5DRW.js → chunk-TXFZUVJP.js} +21 -2
- package/{chunk-DM57ZOTI.js → chunk-YJSTNZGA.js} +8 -3
- package/cloudflare/astro.d.ts +13 -6
- package/cloudflare/astro.js +18 -8
- package/cloudflare/nextjs.d.ts +5 -4
- package/cloudflare/nextjs.js +16 -6
- package/cloudflare/react-router.d.ts +5 -4
- package/cloudflare/react-router.js +18 -8
- package/cloudflare/tanstack-start.d.ts +6 -4
- package/cloudflare/tanstack-start.js +18 -8
- package/cloudflare.d.ts +733 -4
- package/cloudflare.js +26 -8
- package/index.d.ts +3 -3
- package/index.js +2 -2
- package/package.json +19 -13
- package/scripts/appwarden-link.cjs +851 -0
- package/vercel.d.ts +4 -3
- package/vercel.js +34 -6
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict"
|
|
3
|
+
|
|
4
|
+
const fs = require("fs")
|
|
5
|
+
const path = require("path")
|
|
6
|
+
const acorn = require("acorn")
|
|
7
|
+
const { z } = require("zod")
|
|
8
|
+
|
|
9
|
+
const CONFIG_PATH = ".appwarden/linked/middleware.json"
|
|
10
|
+
const GITIGNORE_WARNING =
|
|
11
|
+
"Warning: .appwarden/linked/ is not gitignored. Add it to .gitignore to avoid checking generated config into version control."
|
|
12
|
+
const MAX_FILE_SIZE = 1 * 1024 * 1024 // 1 MB
|
|
13
|
+
const MAX_GITIGNORE_SIZE = 128 * 1024 // 128 KB
|
|
14
|
+
const MAX_REMOTE_RESPONSE_SIZE = 1 * 1024 * 1024 // 1 MB
|
|
15
|
+
const ALLOWED_FRAMEWORKS = [
|
|
16
|
+
"astro",
|
|
17
|
+
"react-router",
|
|
18
|
+
"tanstack-start",
|
|
19
|
+
"nextjs-cloudflare",
|
|
20
|
+
"vercel",
|
|
21
|
+
]
|
|
22
|
+
const ALLOWED_REMOTE_CONFIG_KEYS = [
|
|
23
|
+
"lockPageSlug",
|
|
24
|
+
"debug",
|
|
25
|
+
"contentSecurityPolicy",
|
|
26
|
+
"appwardenApiHostname",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
const ALLOWED_API_HOSTNAMES = ["api.appwarden.io", "staging-api.appwarden.io"]
|
|
30
|
+
|
|
31
|
+
const CSP_ENFORCED = "content-security-policy"
|
|
32
|
+
const CSP_REPORT_ONLY = "content-security-policy-report-only"
|
|
33
|
+
|
|
34
|
+
function isCspHeader(name) {
|
|
35
|
+
return name === CSP_ENFORCED || name === CSP_REPORT_ONLY
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isGlobalRoute(route) {
|
|
39
|
+
if (typeof route !== "string") return false
|
|
40
|
+
const trimmed = route.trim()
|
|
41
|
+
// Cloudflare catch-all routes
|
|
42
|
+
if (trimmed === "/*" || trimmed === "/") return true
|
|
43
|
+
// Next.js / Vercel catch-all patterns
|
|
44
|
+
if (trimmed === "/(.*)" || trimmed === "/:path*" || trimmed === "/:path+")
|
|
45
|
+
return true
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const BuildOutputSchema = z.object({
|
|
50
|
+
lockPageSlug: z
|
|
51
|
+
.string()
|
|
52
|
+
.refine(
|
|
53
|
+
(val) =>
|
|
54
|
+
!val.includes("://") && !val.startsWith("//") && !val.includes("\\"),
|
|
55
|
+
{
|
|
56
|
+
message: "lockPageSlug must be a relative path",
|
|
57
|
+
},
|
|
58
|
+
),
|
|
59
|
+
debug: z.boolean().optional(),
|
|
60
|
+
appwardenApiHostname: z.string().optional(),
|
|
61
|
+
contentSecurityPolicy: z
|
|
62
|
+
.object({
|
|
63
|
+
mode: z.enum(["disabled", "enforced", "report-only"]),
|
|
64
|
+
directives: z.record(
|
|
65
|
+
z.union([z.string(), z.array(z.string()), z.boolean()]),
|
|
66
|
+
),
|
|
67
|
+
})
|
|
68
|
+
.optional(),
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const CYAN = "\x1b[36m"
|
|
72
|
+
const YELLOW = "\x1b[33m"
|
|
73
|
+
|
|
74
|
+
const print = (...args) => console.log(`${CYAN}[appwarden]`, ...args)
|
|
75
|
+
const warn = (...args) => console.warn(`${YELLOW}[appwarden]`, ...args)
|
|
76
|
+
|
|
77
|
+
function parseArgs() {
|
|
78
|
+
const args = process.argv.slice(2)
|
|
79
|
+
const result = {
|
|
80
|
+
framework: null,
|
|
81
|
+
staging: false,
|
|
82
|
+
cwd: process.cwd(),
|
|
83
|
+
fqdn: null,
|
|
84
|
+
}
|
|
85
|
+
for (const arg of args) {
|
|
86
|
+
const frameworkMatch = arg.match(/^--framework=(.+)$/)
|
|
87
|
+
if (frameworkMatch) {
|
|
88
|
+
const fw = frameworkMatch[1]
|
|
89
|
+
if (ALLOWED_FRAMEWORKS.includes(fw)) {
|
|
90
|
+
result.framework = fw
|
|
91
|
+
} else {
|
|
92
|
+
warn(`Ignoring unsupported framework: ${fw}`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (arg === "--staging") result.staging = true
|
|
96
|
+
|
|
97
|
+
const cwdMatch = arg.match(/^--cwd=(.+)$/)
|
|
98
|
+
if (cwdMatch) {
|
|
99
|
+
const resolved = path.resolve(cwdMatch[1])
|
|
100
|
+
const relativeToProcess = path.relative(process.cwd(), resolved)
|
|
101
|
+
if (
|
|
102
|
+
relativeToProcess.startsWith("..") ||
|
|
103
|
+
path.isAbsolute(relativeToProcess)
|
|
104
|
+
) {
|
|
105
|
+
warn(
|
|
106
|
+
`Ignoring --cwd that escapes process working directory: ${cwdMatch[1]}`,
|
|
107
|
+
)
|
|
108
|
+
} else {
|
|
109
|
+
result.cwd = resolved
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const fqdnMatch = arg.match(/^--fqdn=(.+)$/)
|
|
114
|
+
if (fqdnMatch) result.fqdn = fqdnMatch[1]
|
|
115
|
+
}
|
|
116
|
+
return result
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function detectFramework(cwd, explicit) {
|
|
120
|
+
if (explicit) return explicit
|
|
121
|
+
const pkgPath = path.join(cwd, "package.json")
|
|
122
|
+
const pkgContent = safeReadFile(pkgPath, cwd)
|
|
123
|
+
if (!pkgContent) return null
|
|
124
|
+
try {
|
|
125
|
+
const pkg = JSON.parse(pkgContent)
|
|
126
|
+
const deps = {
|
|
127
|
+
...pkg.dependencies,
|
|
128
|
+
...pkg.devDependencies,
|
|
129
|
+
...pkg.peerDependencies,
|
|
130
|
+
}
|
|
131
|
+
if (deps["astro"]) return "astro"
|
|
132
|
+
if (deps["@react-router/cloudflare"]) return "react-router"
|
|
133
|
+
if (
|
|
134
|
+
deps["@tanstack/start"] ||
|
|
135
|
+
deps["@tanstack/react-start"] ||
|
|
136
|
+
deps["@tanstack/solid-start"] ||
|
|
137
|
+
deps["@tanstack/vue-start"] ||
|
|
138
|
+
deps["@tanstack/svelte-start"]
|
|
139
|
+
)
|
|
140
|
+
return "tanstack-start"
|
|
141
|
+
if (deps["@opennextjs/cloudflare"]) return "nextjs-cloudflare"
|
|
142
|
+
if (deps["next"]) return "vercel"
|
|
143
|
+
return null
|
|
144
|
+
} catch {
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function findNextConfig(cwd) {
|
|
150
|
+
// Only .js/.mjs because extractHeadersFromNextConfig uses plain Acorn,
|
|
151
|
+
// which cannot parse TypeScript syntax.
|
|
152
|
+
const candidates = ["next.config.js", "next.config.mjs"]
|
|
153
|
+
for (const c of candidates) {
|
|
154
|
+
const p = path.join(cwd, c)
|
|
155
|
+
if (fs.existsSync(p)) return p
|
|
156
|
+
}
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function extractHeadersFromNextConfig(source) {
|
|
161
|
+
let ast
|
|
162
|
+
try {
|
|
163
|
+
ast = acorn.parse(source, {
|
|
164
|
+
ecmaVersion: 2023,
|
|
165
|
+
sourceType: "module",
|
|
166
|
+
allowReturnOutsideFunction: true,
|
|
167
|
+
})
|
|
168
|
+
} catch {
|
|
169
|
+
try {
|
|
170
|
+
ast = acorn.parse(source, {
|
|
171
|
+
ecmaVersion: 2023,
|
|
172
|
+
sourceType: "script",
|
|
173
|
+
allowReturnOutsideFunction: true,
|
|
174
|
+
})
|
|
175
|
+
} catch {
|
|
176
|
+
return []
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const result = []
|
|
181
|
+
const visited = new Set()
|
|
182
|
+
const MAX_WALK_DEPTH = 500
|
|
183
|
+
|
|
184
|
+
function walk(node, inHeadersFn, depth = 0) {
|
|
185
|
+
if (!node || typeof node !== "object") return
|
|
186
|
+
if (visited.has(node)) return
|
|
187
|
+
if (depth > MAX_WALK_DEPTH) return
|
|
188
|
+
visited.add(node)
|
|
189
|
+
|
|
190
|
+
const headersArray =
|
|
191
|
+
inHeadersFn &&
|
|
192
|
+
node.type === "ReturnStatement" &&
|
|
193
|
+
node.argument &&
|
|
194
|
+
node.argument.type === "ArrayExpression"
|
|
195
|
+
? node.argument
|
|
196
|
+
: inHeadersFn && node.type === "ArrayExpression"
|
|
197
|
+
? node
|
|
198
|
+
: null
|
|
199
|
+
|
|
200
|
+
if (headersArray) {
|
|
201
|
+
for (const el of headersArray.elements || []) {
|
|
202
|
+
if (!el || el.type !== "ObjectExpression") continue
|
|
203
|
+
let sourceValue = null
|
|
204
|
+
for (const prop of el.properties || []) {
|
|
205
|
+
if (
|
|
206
|
+
prop.type === "Property" &&
|
|
207
|
+
prop.key &&
|
|
208
|
+
(prop.key.name === "source" || prop.key.value === "source") &&
|
|
209
|
+
prop.value &&
|
|
210
|
+
prop.value.type === "Literal"
|
|
211
|
+
) {
|
|
212
|
+
sourceValue = prop.value.value
|
|
213
|
+
break
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (sourceValue && !isGlobalRoute(sourceValue)) {
|
|
217
|
+
warn(
|
|
218
|
+
`Skipping route-specific CSP from Next.js source: ${sourceValue}`,
|
|
219
|
+
)
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
for (const prop of el.properties || []) {
|
|
223
|
+
if (
|
|
224
|
+
prop.type === "Property" &&
|
|
225
|
+
prop.key &&
|
|
226
|
+
(prop.key.name === "headers" || prop.key.value === "headers") &&
|
|
227
|
+
prop.value &&
|
|
228
|
+
prop.value.type === "ArrayExpression"
|
|
229
|
+
) {
|
|
230
|
+
for (const h of prop.value.elements || []) {
|
|
231
|
+
if (!h || h.type !== "ObjectExpression") continue
|
|
232
|
+
let key = null
|
|
233
|
+
let value = null
|
|
234
|
+
for (const hp of h.properties || []) {
|
|
235
|
+
if (hp.type !== "Property" || !hp.key) continue
|
|
236
|
+
const kName = hp.key.name || hp.key.value
|
|
237
|
+
if (
|
|
238
|
+
kName === "key" &&
|
|
239
|
+
hp.value &&
|
|
240
|
+
hp.value.type === "Literal"
|
|
241
|
+
) {
|
|
242
|
+
key = hp.value.value
|
|
243
|
+
}
|
|
244
|
+
if (
|
|
245
|
+
kName === "value" &&
|
|
246
|
+
hp.value &&
|
|
247
|
+
hp.value.type === "Literal"
|
|
248
|
+
) {
|
|
249
|
+
value = hp.value.value
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (key !== null && value !== null) {
|
|
253
|
+
result.push({ key, value })
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (
|
|
263
|
+
node.type === "FunctionDeclaration" ||
|
|
264
|
+
node.type === "FunctionExpression" ||
|
|
265
|
+
node.type === "ArrowFunctionExpression"
|
|
266
|
+
) {
|
|
267
|
+
const isHeaders = node.id && node.id.name === "headers"
|
|
268
|
+
const newInHeaders = inHeadersFn || isHeaders
|
|
269
|
+
for (const k of Object.keys(node)) {
|
|
270
|
+
if (k === "id" || k === "params") continue
|
|
271
|
+
walk(node[k], newInHeaders, depth + 1)
|
|
272
|
+
}
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (
|
|
277
|
+
node.type === "Property" &&
|
|
278
|
+
node.key &&
|
|
279
|
+
(node.key.name === "headers" || node.key.value === "headers")
|
|
280
|
+
) {
|
|
281
|
+
if (
|
|
282
|
+
node.value &&
|
|
283
|
+
(node.value.type === "FunctionExpression" ||
|
|
284
|
+
node.value.type === "ArrowFunctionExpression")
|
|
285
|
+
) {
|
|
286
|
+
walk(node.value, true, depth + 1)
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
for (const k of Object.keys(node)) {
|
|
292
|
+
if (k === "parent") continue
|
|
293
|
+
walk(node[k], inHeadersFn, depth + 1)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
walk(ast, false)
|
|
298
|
+
return result
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function splitCspTokens(value) {
|
|
302
|
+
const tokens = []
|
|
303
|
+
let current = ""
|
|
304
|
+
let inQuotes = false
|
|
305
|
+
for (let i = 0; i < value.length; i++) {
|
|
306
|
+
const ch = value[i]
|
|
307
|
+
if (ch === "'") {
|
|
308
|
+
inQuotes = !inQuotes
|
|
309
|
+
current += ch
|
|
310
|
+
continue
|
|
311
|
+
}
|
|
312
|
+
if (!inQuotes && /\s/.test(ch)) {
|
|
313
|
+
if (current) {
|
|
314
|
+
tokens.push(current)
|
|
315
|
+
current = ""
|
|
316
|
+
}
|
|
317
|
+
continue
|
|
318
|
+
}
|
|
319
|
+
current += ch
|
|
320
|
+
}
|
|
321
|
+
if (current) tokens.push(current)
|
|
322
|
+
return tokens
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function parseCspHeaderValue(value) {
|
|
326
|
+
const directives = Object.create(null)
|
|
327
|
+
if (!value || typeof value !== "string") return directives
|
|
328
|
+
const parts = value.split(/;\s*/)
|
|
329
|
+
for (const part of parts) {
|
|
330
|
+
const trimmed = part.trim()
|
|
331
|
+
if (!trimmed) continue
|
|
332
|
+
const firstSpace = trimmed.indexOf(" ")
|
|
333
|
+
const name = firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace)
|
|
334
|
+
const directiveValue =
|
|
335
|
+
firstSpace === -1 ? "" : trimmed.slice(firstSpace + 1).trim()
|
|
336
|
+
if (!directiveValue) {
|
|
337
|
+
directives[name] = true
|
|
338
|
+
} else {
|
|
339
|
+
directives[name] = splitCspTokens(directiveValue).map((token) => {
|
|
340
|
+
// Normalize quoted nonce placeholders so downstream makeCSPHeader
|
|
341
|
+
// does not produce double-quoted nonce sources.
|
|
342
|
+
if (token === "'{{nonce}}'" || token === '"{{nonce}}"') {
|
|
343
|
+
return "{{nonce}}"
|
|
344
|
+
}
|
|
345
|
+
return token
|
|
346
|
+
})
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return directives
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function parseCloudflareHeaders(content) {
|
|
353
|
+
const lines = content.split(/\r?\n/)
|
|
354
|
+
const result = []
|
|
355
|
+
let currentRoute = null
|
|
356
|
+
for (const line of lines) {
|
|
357
|
+
const trimmed = line.trim()
|
|
358
|
+
if (!trimmed || trimmed.startsWith("#")) continue
|
|
359
|
+
if (!line.startsWith(" ") && !line.startsWith("\t")) {
|
|
360
|
+
// This is a route line, not an indented header
|
|
361
|
+
currentRoute = trimmed
|
|
362
|
+
continue
|
|
363
|
+
}
|
|
364
|
+
const colonIdx = trimmed.indexOf(":")
|
|
365
|
+
if (colonIdx === -1) continue
|
|
366
|
+
const name = trimmed.slice(0, colonIdx).trim().toLowerCase()
|
|
367
|
+
const value = trimmed.slice(colonIdx + 1).trim()
|
|
368
|
+
if (isCspHeader(name)) {
|
|
369
|
+
if (currentRoute && !isGlobalRoute(currentRoute)) {
|
|
370
|
+
warn(
|
|
371
|
+
`Skipping route-specific CSP from non-global route: ${currentRoute}`,
|
|
372
|
+
)
|
|
373
|
+
continue
|
|
374
|
+
}
|
|
375
|
+
result.push({ key: name, value })
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return result
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function pushCspHeaders(headers, rawHeaders) {
|
|
382
|
+
for (const h of rawHeaders || []) {
|
|
383
|
+
const key = (h.key || "").toLowerCase()
|
|
384
|
+
if (isCspHeader(key)) {
|
|
385
|
+
headers.push({ key: h.key, value: h.value })
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function safeReadFile(filePath, cwd) {
|
|
391
|
+
if (!filePath) return null
|
|
392
|
+
try {
|
|
393
|
+
const resolved = fs.realpathSync(filePath)
|
|
394
|
+
const root = path.resolve(cwd)
|
|
395
|
+
const relative = path.relative(root, resolved)
|
|
396
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
397
|
+
warn(`Skipping file outside project: ${filePath}`)
|
|
398
|
+
return null
|
|
399
|
+
}
|
|
400
|
+
const fd = fs.openSync(resolved, "r")
|
|
401
|
+
try {
|
|
402
|
+
const stats = fs.fstatSync(fd)
|
|
403
|
+
if (!stats.isFile()) {
|
|
404
|
+
warn(`Skipping non-file path: ${filePath}`)
|
|
405
|
+
return null
|
|
406
|
+
}
|
|
407
|
+
if (stats.size > MAX_FILE_SIZE) {
|
|
408
|
+
warn(`Skipping oversized file: ${filePath}`)
|
|
409
|
+
return null
|
|
410
|
+
}
|
|
411
|
+
return fs.readFileSync(fd, "utf-8")
|
|
412
|
+
} finally {
|
|
413
|
+
fs.closeSync(fd)
|
|
414
|
+
}
|
|
415
|
+
} catch {
|
|
416
|
+
return null
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function safeWriteFile(filePath, content, cwd) {
|
|
421
|
+
try {
|
|
422
|
+
const root = fs.realpathSync(path.resolve(cwd))
|
|
423
|
+
const target = path.resolve(filePath)
|
|
424
|
+
const relative = path.relative(root, target)
|
|
425
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
426
|
+
warn(`Refusing to write outside project: ${filePath}`)
|
|
427
|
+
return false
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Walk up the path and verify no existing component is a symlink
|
|
431
|
+
let current = target
|
|
432
|
+
while (current !== root && current !== path.dirname(current)) {
|
|
433
|
+
if (fs.existsSync(current)) {
|
|
434
|
+
const stat = fs.lstatSync(current)
|
|
435
|
+
if (stat.isSymbolicLink()) {
|
|
436
|
+
warn(`Refusing to write through symlink: ${current}`)
|
|
437
|
+
return false
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
current = path.dirname(current)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const dir = path.dirname(target)
|
|
444
|
+
if (!fs.existsSync(dir)) {
|
|
445
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Re-verify directory path after mkdir to catch symlink races in parent dirs
|
|
449
|
+
const resolvedDir = fs.realpathSync(dir)
|
|
450
|
+
const dirRelative = path.relative(root, resolvedDir)
|
|
451
|
+
if (dirRelative.startsWith("..") || path.isAbsolute(dirRelative)) {
|
|
452
|
+
warn(`Refusing to write outside project: ${dir}`)
|
|
453
|
+
return false
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Atomic write: temp file in same directory, then rename
|
|
457
|
+
const tempFile = path.join(
|
|
458
|
+
dir,
|
|
459
|
+
`.appwarden-link-tmp-${process.hrtime.bigint().toString(36)}.json`,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
fs.writeFileSync(tempFile, content, { mode: 0o600 })
|
|
463
|
+
fs.renameSync(tempFile, target)
|
|
464
|
+
|
|
465
|
+
// Final verification: ensure the written file is a regular file inside the
|
|
466
|
+
// project. The nlink > 1 check specifically defends against a race where an
|
|
467
|
+
// attacker creates a hard link to the temp file in the same directory between
|
|
468
|
+
// writeFileSync and renameSync; it does not protect against a pre-existing
|
|
469
|
+
// hard-linked target because rename atomically swaps inodes.
|
|
470
|
+
const finalStat = fs.lstatSync(target)
|
|
471
|
+
if (!finalStat.isFile() || finalStat.nlink > 1) {
|
|
472
|
+
warn(`Refusing to write through link: ${target}`)
|
|
473
|
+
return false
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const finalResolved = fs.realpathSync(target)
|
|
477
|
+
const finalRelative = path.relative(root, finalResolved)
|
|
478
|
+
if (finalRelative.startsWith("..") || path.isAbsolute(finalRelative)) {
|
|
479
|
+
warn(`Refusing to write outside project: ${target}`)
|
|
480
|
+
return false
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return true
|
|
484
|
+
} catch (err) {
|
|
485
|
+
warn(`Failed to write config: ${err.message}`)
|
|
486
|
+
return false
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function extractLocalHeadersConfig(cwd, framework) {
|
|
491
|
+
const headers = []
|
|
492
|
+
const nextConfig = findNextConfig(cwd)
|
|
493
|
+
const nextConfigSource = safeReadFile(nextConfig, cwd)
|
|
494
|
+
if (nextConfigSource) {
|
|
495
|
+
headers.push(...extractHeadersFromNextConfig(nextConfigSource))
|
|
496
|
+
}
|
|
497
|
+
const vercelJson = path.join(cwd, "vercel.json")
|
|
498
|
+
const vercelJsonContent = safeReadFile(vercelJson, cwd)
|
|
499
|
+
if (vercelJsonContent) {
|
|
500
|
+
try {
|
|
501
|
+
const vj = JSON.parse(vercelJsonContent)
|
|
502
|
+
for (const rule of [...(vj.headers || []), ...(vj.routes || [])]) {
|
|
503
|
+
const route = rule.source || rule.src || ""
|
|
504
|
+
if (route && !isGlobalRoute(route)) {
|
|
505
|
+
warn(`Skipping route-specific CSP from vercel.json rule: ${route}`)
|
|
506
|
+
continue
|
|
507
|
+
}
|
|
508
|
+
// Legacy vercel.json routes[] may use an object map for headers
|
|
509
|
+
let ruleHeaders = rule.headers
|
|
510
|
+
if (ruleHeaders && !Array.isArray(ruleHeaders)) {
|
|
511
|
+
ruleHeaders = Object.entries(ruleHeaders).map(([key, value]) => ({
|
|
512
|
+
key,
|
|
513
|
+
value,
|
|
514
|
+
}))
|
|
515
|
+
}
|
|
516
|
+
pushCspHeaders(headers, ruleHeaders)
|
|
517
|
+
}
|
|
518
|
+
} catch {}
|
|
519
|
+
}
|
|
520
|
+
const publicHeaders = path.join(cwd, "public", "_headers")
|
|
521
|
+
const staticHeaders = path.join(cwd, "static", "_headers")
|
|
522
|
+
const distHeaders = path.join(cwd, "dist", "_headers")
|
|
523
|
+
const buildHeaders = path.join(cwd, "build", "_headers")
|
|
524
|
+
const nextHeaders = path.join(cwd, ".next", "_headers")
|
|
525
|
+
for (const hp of [
|
|
526
|
+
publicHeaders,
|
|
527
|
+
staticHeaders,
|
|
528
|
+
distHeaders,
|
|
529
|
+
buildHeaders,
|
|
530
|
+
nextHeaders,
|
|
531
|
+
]) {
|
|
532
|
+
const content = safeReadFile(hp, cwd)
|
|
533
|
+
if (content) {
|
|
534
|
+
headers.push(...parseCloudflareHeaders(content))
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if (!headers.length) return null
|
|
538
|
+
const enforced = headers.find((h) => h.key.toLowerCase() === CSP_ENFORCED)
|
|
539
|
+
const reportOnly = headers.find(
|
|
540
|
+
(h) => h.key.toLowerCase() === CSP_REPORT_ONLY,
|
|
541
|
+
)
|
|
542
|
+
if (enforced) {
|
|
543
|
+
return { mode: "enforced", directives: parseCspHeaderValue(enforced.value) }
|
|
544
|
+
}
|
|
545
|
+
if (reportOnly) {
|
|
546
|
+
return {
|
|
547
|
+
mode: "report-only",
|
|
548
|
+
directives: parseCspHeaderValue(reportOnly.value),
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return null
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function isAllowedApiHostname(value) {
|
|
555
|
+
try {
|
|
556
|
+
const url = new URL(value)
|
|
557
|
+
return (
|
|
558
|
+
url.protocol === "https:" && ALLOWED_API_HOSTNAMES.includes(url.hostname)
|
|
559
|
+
)
|
|
560
|
+
} catch {
|
|
561
|
+
return false
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function mapKeys(obj, transform) {
|
|
566
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
|
567
|
+
return obj
|
|
568
|
+
}
|
|
569
|
+
const result = Object.create(null)
|
|
570
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
571
|
+
result[transform(key)] = value
|
|
572
|
+
}
|
|
573
|
+
return result
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const normalizeRemoteConfigKeys = (obj) =>
|
|
577
|
+
mapKeys(obj, (key) => key.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase()))
|
|
578
|
+
|
|
579
|
+
const kebabCaseDirectives = (obj) =>
|
|
580
|
+
mapKeys(obj, (key) => key.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase())
|
|
581
|
+
|
|
582
|
+
function sanitizeRemoteConfig(data, depth = 0) {
|
|
583
|
+
if (depth > 5) {
|
|
584
|
+
warn("Remote config nested too deeply. Truncating.")
|
|
585
|
+
return null
|
|
586
|
+
}
|
|
587
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
588
|
+
warn("Remote config response was not a valid object. Ignoring.")
|
|
589
|
+
return null
|
|
590
|
+
}
|
|
591
|
+
const safe = Object.create(null)
|
|
592
|
+
for (const key of Object.keys(data)) {
|
|
593
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") {
|
|
594
|
+
continue
|
|
595
|
+
}
|
|
596
|
+
// Top-level keys must be explicitly allowlisted
|
|
597
|
+
if (depth === 0 && !ALLOWED_REMOTE_CONFIG_KEYS.includes(key)) {
|
|
598
|
+
warn(`Ignoring unexpected remote config key: ${key}`)
|
|
599
|
+
continue
|
|
600
|
+
}
|
|
601
|
+
const val = data[key]
|
|
602
|
+
// Reject untrusted API hostnames to prevent token exfiltration
|
|
603
|
+
if (
|
|
604
|
+
key === "appwardenApiHostname" &&
|
|
605
|
+
typeof val === "string" &&
|
|
606
|
+
!isAllowedApiHostname(val)
|
|
607
|
+
) {
|
|
608
|
+
warn(`Ignoring invalid appwardenApiHostname: ${val}`)
|
|
609
|
+
continue
|
|
610
|
+
}
|
|
611
|
+
// Only accept primitive values, arrays of strings, and plain objects
|
|
612
|
+
if (val === null || val === undefined) {
|
|
613
|
+
safe[key] = val
|
|
614
|
+
} else if (
|
|
615
|
+
typeof val === "string" ||
|
|
616
|
+
typeof val === "boolean" ||
|
|
617
|
+
typeof val === "number"
|
|
618
|
+
) {
|
|
619
|
+
safe[key] = val
|
|
620
|
+
} else if (Array.isArray(val) && val.every((v) => typeof v === "string")) {
|
|
621
|
+
safe[key] = val
|
|
622
|
+
} else if (typeof val === "object") {
|
|
623
|
+
// Recursively sanitize nested objects (e.g. contentSecurityPolicy.directives)
|
|
624
|
+
const nested = sanitizeRemoteConfig(val, depth + 1)
|
|
625
|
+
if (nested) safe[key] = nested
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return safe
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function fetchRemoteConfig(apiToken, apiHostname, fqdn) {
|
|
632
|
+
if (!apiToken || !fqdn) return null
|
|
633
|
+
if (
|
|
634
|
+
typeof fqdn !== "string" ||
|
|
635
|
+
!fqdn.trim() ||
|
|
636
|
+
/[\/\\]/.test(fqdn) ||
|
|
637
|
+
fqdn.includes("..")
|
|
638
|
+
) {
|
|
639
|
+
warn("Invalid FQDN provided. Skipping.")
|
|
640
|
+
return null
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const url = new URL("/v1/appwarden/config", apiHostname)
|
|
644
|
+
url.searchParams.set("fqdn", fqdn)
|
|
645
|
+
const controller = new AbortController()
|
|
646
|
+
const timeout = setTimeout(() => controller.abort(), 10_000)
|
|
647
|
+
const href = url.toString()
|
|
648
|
+
try {
|
|
649
|
+
const res = await fetch(href, {
|
|
650
|
+
headers: { Authorization: apiToken },
|
|
651
|
+
signal: controller.signal,
|
|
652
|
+
})
|
|
653
|
+
if (!res.ok) {
|
|
654
|
+
warn(`fetch failed: ${href} ${res.status} ${res.statusText}`)
|
|
655
|
+
return null
|
|
656
|
+
}
|
|
657
|
+
const contentLength = res.headers.get("content-length")
|
|
658
|
+
if (
|
|
659
|
+
contentLength &&
|
|
660
|
+
parseInt(contentLength, 10) > MAX_REMOTE_RESPONSE_SIZE
|
|
661
|
+
) {
|
|
662
|
+
warn(`Remote response too large: ${contentLength} bytes`)
|
|
663
|
+
return null
|
|
664
|
+
}
|
|
665
|
+
let text = ""
|
|
666
|
+
let bytes = 0
|
|
667
|
+
const reader = res.body.getReader()
|
|
668
|
+
const decoder = new TextDecoder("utf-8")
|
|
669
|
+
while (true) {
|
|
670
|
+
const { done, value } = await reader.read()
|
|
671
|
+
if (done) break
|
|
672
|
+
bytes += value.length
|
|
673
|
+
if (bytes > MAX_REMOTE_RESPONSE_SIZE) {
|
|
674
|
+
warn("Remote response body too large")
|
|
675
|
+
try {
|
|
676
|
+
await reader.cancel()
|
|
677
|
+
} catch {}
|
|
678
|
+
return null
|
|
679
|
+
}
|
|
680
|
+
text += decoder.decode(value, { stream: true })
|
|
681
|
+
}
|
|
682
|
+
text += decoder.decode()
|
|
683
|
+
|
|
684
|
+
const data = JSON.parse(text)
|
|
685
|
+
|
|
686
|
+
// The API returns { content: [{ url, options }] } — extract the config object
|
|
687
|
+
let config = null
|
|
688
|
+
if (data && Array.isArray(data.content)) {
|
|
689
|
+
const entry = data.content.find(
|
|
690
|
+
(item) =>
|
|
691
|
+
item.url === fqdn ||
|
|
692
|
+
item.url === `www.${fqdn}` ||
|
|
693
|
+
fqdn === `www.${item.url}`,
|
|
694
|
+
)
|
|
695
|
+
config = entry?.options ?? null
|
|
696
|
+
} else if (data && typeof data === "object" && !Array.isArray(data)) {
|
|
697
|
+
// Fallback: API returned a flat object directly
|
|
698
|
+
config = data
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (config) {
|
|
702
|
+
config = normalizeRemoteConfigKeys(config)
|
|
703
|
+
// Normalize API-specific CSP keys into the middleware shape
|
|
704
|
+
if (config.cspMode || config.cspDirectives) {
|
|
705
|
+
config.contentSecurityPolicy = {
|
|
706
|
+
mode: config.cspMode || "report-only",
|
|
707
|
+
directives: kebabCaseDirectives(config.cspDirectives) || {},
|
|
708
|
+
}
|
|
709
|
+
delete config.cspMode
|
|
710
|
+
delete config.cspDirectives
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
return sanitizeRemoteConfig(config)
|
|
715
|
+
} catch (err) {
|
|
716
|
+
warn(`fetch error: ${href} ${err.message}`)
|
|
717
|
+
return null
|
|
718
|
+
} finally {
|
|
719
|
+
clearTimeout(timeout)
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function mergeConfigs(remote, localHeaders) {
|
|
724
|
+
const merged = Object.create(null)
|
|
725
|
+
if (remote && typeof remote === "object") {
|
|
726
|
+
for (const key of Object.keys(remote)) {
|
|
727
|
+
merged[key] = remote[key]
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
if (localHeaders) {
|
|
731
|
+
merged.contentSecurityPolicy = localHeaders
|
|
732
|
+
}
|
|
733
|
+
return merged
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function checkGitignore(cwd) {
|
|
737
|
+
if (process.env.APPWARDEN_SKIP_GITIGNORE_CHECK === "1") return true
|
|
738
|
+
let current = path.resolve(cwd)
|
|
739
|
+
const root = path.parse(current).root
|
|
740
|
+
while (current !== root) {
|
|
741
|
+
const gitignorePath = path.join(current, ".gitignore")
|
|
742
|
+
try {
|
|
743
|
+
const resolved = fs.realpathSync(gitignorePath)
|
|
744
|
+
const resolvedDir = fs.realpathSync(current)
|
|
745
|
+
if (path.dirname(resolved) !== resolvedDir) {
|
|
746
|
+
warn(`Skipping suspicious gitignore symlink: ${resolved}`)
|
|
747
|
+
return false
|
|
748
|
+
}
|
|
749
|
+
if (!resolved.endsWith(path.sep + ".gitignore")) {
|
|
750
|
+
warn(`Skipping suspicious gitignore symlink: ${resolved}`)
|
|
751
|
+
return false
|
|
752
|
+
}
|
|
753
|
+
const fd = fs.openSync(resolved, "r")
|
|
754
|
+
try {
|
|
755
|
+
const stats = fs.fstatSync(fd)
|
|
756
|
+
if (!stats.isFile()) {
|
|
757
|
+
warn(`Skipping non-file .gitignore: ${resolved}`)
|
|
758
|
+
return false
|
|
759
|
+
}
|
|
760
|
+
if (stats.size > MAX_GITIGNORE_SIZE) {
|
|
761
|
+
warn(`Skipping oversized .gitignore: ${resolved}`)
|
|
762
|
+
return false
|
|
763
|
+
}
|
|
764
|
+
const content = fs.readFileSync(fd, "utf-8")
|
|
765
|
+
if (content.includes(CONFIG_PATH) || content.includes(".appwarden/")) {
|
|
766
|
+
return true
|
|
767
|
+
}
|
|
768
|
+
} finally {
|
|
769
|
+
fs.closeSync(fd)
|
|
770
|
+
}
|
|
771
|
+
} catch (err) {
|
|
772
|
+
if (err.code === "ENOENT") {
|
|
773
|
+
// .gitignore doesn't exist in this directory; continue searching up
|
|
774
|
+
} else {
|
|
775
|
+
return false
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
const parent = path.dirname(current)
|
|
779
|
+
if (parent === current) break
|
|
780
|
+
current = parent
|
|
781
|
+
}
|
|
782
|
+
return false
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
async function main() {
|
|
786
|
+
if (process.env.APPWARDEN_SKIP_POSTBUILD === "1") {
|
|
787
|
+
print("APPWARDEN_SKIP_POSTBUILD=1 detected, skipping.")
|
|
788
|
+
process.exit(0)
|
|
789
|
+
}
|
|
790
|
+
const args = parseArgs()
|
|
791
|
+
const cwd = args.cwd
|
|
792
|
+
const framework = detectFramework(cwd, args.framework)
|
|
793
|
+
if (!framework) {
|
|
794
|
+
warn("Could not detect framework. Use --framework=<name> to specify.")
|
|
795
|
+
} else {
|
|
796
|
+
print(`Detected framework: ${framework}`)
|
|
797
|
+
}
|
|
798
|
+
const apiToken = process.env.APPWARDEN_API_TOKEN || null
|
|
799
|
+
const apiHostname = args.staging
|
|
800
|
+
? "https://staging-api.appwarden.io"
|
|
801
|
+
: "https://api.appwarden.io"
|
|
802
|
+
const fqdn = args.fqdn || process.env.APPWARDEN_FQDN || null
|
|
803
|
+
if (args.staging) {
|
|
804
|
+
print("Using staging API hostname")
|
|
805
|
+
}
|
|
806
|
+
if (apiToken) {
|
|
807
|
+
print(`Using provided Appwarden API token`)
|
|
808
|
+
} else {
|
|
809
|
+
print("APPWARDEN_API_TOKEN not set. Using local headers and defaults only.")
|
|
810
|
+
}
|
|
811
|
+
const localHeaders = extractLocalHeadersConfig(cwd, framework)
|
|
812
|
+
if (localHeaders) {
|
|
813
|
+
print("Found local CSP headers configuration.")
|
|
814
|
+
}
|
|
815
|
+
const remote = await fetchRemoteConfig(apiToken, apiHostname, fqdn)
|
|
816
|
+
if (remote) {
|
|
817
|
+
print("Fetched remote Appwarden configuration.")
|
|
818
|
+
}
|
|
819
|
+
const merged = mergeConfigs(remote, localHeaders)
|
|
820
|
+
if (!merged.lockPageSlug && merged.lockPageSlug !== "") {
|
|
821
|
+
merged.lockPageSlug = ""
|
|
822
|
+
}
|
|
823
|
+
const outDir = path.join(cwd, ".appwarden", "linked")
|
|
824
|
+
const outPath = path.join(outDir, "middleware.json")
|
|
825
|
+
const safeConfig = { ...merged }
|
|
826
|
+
delete safeConfig.appwardenApiToken
|
|
827
|
+
const validation = BuildOutputSchema.safeParse(safeConfig)
|
|
828
|
+
if (!validation.success) {
|
|
829
|
+
warn("Generated config failed validation:", validation.error.message)
|
|
830
|
+
process.exit(1)
|
|
831
|
+
}
|
|
832
|
+
const writeOk = safeWriteFile(
|
|
833
|
+
outPath,
|
|
834
|
+
JSON.stringify(safeConfig, null, 2) + "\n",
|
|
835
|
+
cwd,
|
|
836
|
+
)
|
|
837
|
+
if (writeOk) {
|
|
838
|
+
print(`Wrote merged configuration to ${outPath}`)
|
|
839
|
+
} else {
|
|
840
|
+
warn(`Failed to write merged configuration to ${outPath}`)
|
|
841
|
+
process.exit(1)
|
|
842
|
+
}
|
|
843
|
+
if (!checkGitignore(cwd)) {
|
|
844
|
+
warn(GITIGNORE_WARNING)
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
main().catch((err) => {
|
|
849
|
+
console.error("[appwarden] Fatal error:", err)
|
|
850
|
+
process.exit(1)
|
|
851
|
+
})
|