UNPKG

@appwarden/middleware

Version:

Instantly disable all user interaction with your app deployed on Cloudflare or Vercel

888 lines (833 loc) 26.3 kB
#!/usr/bin/env node "use strict" const fs = require("fs") const path = require("path") const acorn = require("acorn") const { z } = require("zod") const CONFIG_PATH = ".appwarden/linked/middleware.json" const GITIGNORE_WARNING = "Warning: .appwarden/linked/ is not gitignored. Add it to .gitignore to avoid checking generated config into version control." const MAX_FILE_SIZE = 1 * 1024 * 1024 // 1 MB const MAX_GITIGNORE_SIZE = 128 * 1024 // 128 KB const MAX_REMOTE_RESPONSE_SIZE = 1 * 1024 * 1024 // 1 MB const ALLOWED_FRAMEWORKS = [ "astro", "react-router", "tanstack-start", "nextjs-cloudflare", "vercel", ] const ALLOWED_REMOTE_CONFIG_KEYS = [ "lockPageSlug", "debug", "contentSecurityPolicy", "appwardenApiHostname", ] const ALLOWED_API_HOSTNAMES = ["api.appwarden.io", "staging-api.appwarden.io"] const CSP_ENFORCED = "content-security-policy" const CSP_REPORT_ONLY = "content-security-policy-report-only" function isCspHeader(name) { return name === CSP_ENFORCED || name === CSP_REPORT_ONLY } function isGlobalRoute(route) { if (typeof route !== "string") return false const trimmed = route.trim() // Cloudflare catch-all routes if (trimmed === "/*" || trimmed === "/") return true // Next.js / Vercel catch-all patterns if (trimmed === "/(.*)" || trimmed === "/:path*" || trimmed === "/:path+") return true return false } const BuildOutputSchema = z.object({ lockPageSlug: z .string() .refine( (val) => !val.includes("://") && !val.startsWith("//") && !val.includes("\\"), { message: "lockPageSlug must be a relative path", }, ), debug: z.boolean().optional(), appwardenApiHostname: z.string().optional(), contentSecurityPolicy: z .object({ mode: z.enum(["disabled", "enforced", "report-only"]), directives: z.record( z.union([z.string(), z.array(z.string()), z.boolean()]), ), }) .optional(), }) const CYAN = "\x1b[36m" const YELLOW = "\x1b[33m" const print = (...args) => console.log(`${CYAN}[appwarden]`, ...args) const warn = (...args) => console.warn(`${YELLOW}[appwarden]`, ...args) const makeDebug = (enabled) => (...args) => { if (enabled) console.log(`${CYAN}[appwarden]`, ...args) } function parseArgs() { const args = process.argv.slice(2) const result = { framework: null, staging: false, cwd: process.cwd(), fqdn: null, debug: false, } for (const arg of args) { const frameworkMatch = arg.match(/^--framework=(.+)$/) if (frameworkMatch) { const fw = frameworkMatch[1] if (ALLOWED_FRAMEWORKS.includes(fw)) { result.framework = fw } else { warn(`Ignoring unsupported framework: ${fw}`) } } if (arg === "--staging") result.staging = true if (arg === "--debug") result.debug = true const cwdMatch = arg.match(/^--cwd=(.+)$/) if (cwdMatch) { const resolved = path.resolve(cwdMatch[1]) const relativeToProcess = path.relative(process.cwd(), resolved) if ( relativeToProcess.startsWith("..") || path.isAbsolute(relativeToProcess) ) { warn( `Ignoring --cwd that escapes process working directory: ${cwdMatch[1]}`, ) } else { result.cwd = resolved } } const fqdnMatch = arg.match(/^--fqdn=(.+)$/) if (fqdnMatch) result.fqdn = fqdnMatch[1] } return result } function detectFramework(cwd, explicit) { if (explicit) return explicit const pkgPath = path.join(cwd, "package.json") const pkgContent = safeReadFile(pkgPath, cwd) if (!pkgContent) return null try { const pkg = JSON.parse(pkgContent) const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies, } if (deps["astro"]) return "astro" if (deps["@react-router/cloudflare"]) return "react-router" if ( deps["@tanstack/start"] || deps["@tanstack/react-start"] || deps["@tanstack/solid-start"] || deps["@tanstack/vue-start"] || deps["@tanstack/svelte-start"] ) return "tanstack-start" if (deps["@opennextjs/cloudflare"]) return "nextjs-cloudflare" if (deps["next"]) return "vercel" return null } catch { return null } } function findNextConfig(cwd) { // Only .js/.mjs because extractHeadersFromNextConfig uses plain Acorn, // which cannot parse TypeScript syntax. const candidates = ["next.config.js", "next.config.mjs"] for (const c of candidates) { const p = path.join(cwd, c) if (fs.existsSync(p)) return p } return null } function extractHeadersFromNextConfig(source) { let ast try { ast = acorn.parse(source, { ecmaVersion: 2023, sourceType: "module", allowReturnOutsideFunction: true, }) } catch { try { ast = acorn.parse(source, { ecmaVersion: 2023, sourceType: "script", allowReturnOutsideFunction: true, }) } catch { return [] } } const result = [] const visited = new Set() const MAX_WALK_DEPTH = 500 function walk(node, inHeadersFn, depth = 0) { if (!node || typeof node !== "object") return if (visited.has(node)) return if (depth > MAX_WALK_DEPTH) return visited.add(node) const headersArray = inHeadersFn && node.type === "ReturnStatement" && node.argument && node.argument.type === "ArrayExpression" ? node.argument : inHeadersFn && node.type === "ArrayExpression" ? node : null if (headersArray) { for (const el of headersArray.elements || []) { if (!el || el.type !== "ObjectExpression") continue let sourceValue = null for (const prop of el.properties || []) { if ( prop.type === "Property" && prop.key && (prop.key.name === "source" || prop.key.value === "source") && prop.value && prop.value.type === "Literal" ) { sourceValue = prop.value.value break } } if (sourceValue && !isGlobalRoute(sourceValue)) { warn( `Skipping route-specific CSP from Next.js source: ${sourceValue}`, ) continue } for (const prop of el.properties || []) { if ( prop.type === "Property" && prop.key && (prop.key.name === "headers" || prop.key.value === "headers") && prop.value && prop.value.type === "ArrayExpression" ) { for (const h of prop.value.elements || []) { if (!h || h.type !== "ObjectExpression") continue let key = null let value = null for (const hp of h.properties || []) { if (hp.type !== "Property" || !hp.key) continue const kName = hp.key.name || hp.key.value if ( kName === "key" && hp.value && hp.value.type === "Literal" ) { key = hp.value.value } if ( kName === "value" && hp.value && hp.value.type === "Literal" ) { value = hp.value.value } } if (key !== null && value !== null) { result.push({ key, value }) } } } } } return } if ( node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression" ) { const isHeaders = node.id && node.id.name === "headers" const newInHeaders = inHeadersFn || isHeaders for (const k of Object.keys(node)) { if (k === "id" || k === "params") continue walk(node[k], newInHeaders, depth + 1) } return } if ( node.type === "Property" && node.key && (node.key.name === "headers" || node.key.value === "headers") ) { if ( node.value && (node.value.type === "FunctionExpression" || node.value.type === "ArrowFunctionExpression") ) { walk(node.value, true, depth + 1) return } } for (const k of Object.keys(node)) { if (k === "parent") continue walk(node[k], inHeadersFn, depth + 1) } } walk(ast, false) return result } function splitCspTokens(value) { const tokens = [] let current = "" let inQuotes = false for (let i = 0; i < value.length; i++) { const ch = value[i] if (ch === "'") { inQuotes = !inQuotes current += ch continue } if (!inQuotes && /\s/.test(ch)) { if (current) { tokens.push(current) current = "" } continue } current += ch } if (current) tokens.push(current) return tokens } function parseCspHeaderValue(value) { const directives = Object.create(null) if (!value || typeof value !== "string") return directives const parts = value.split(/;\s*/) for (const part of parts) { const trimmed = part.trim() if (!trimmed) continue const firstSpace = trimmed.indexOf(" ") const name = firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace) const directiveValue = firstSpace === -1 ? "" : trimmed.slice(firstSpace + 1).trim() if (!directiveValue) { directives[name] = true } else { directives[name] = splitCspTokens(directiveValue).map((token) => { // Normalize quoted nonce placeholders so downstream makeCSPHeader // does not produce double-quoted nonce sources. if (token === "'{{nonce}}'" || token === '"{{nonce}}"') { return "{{nonce}}" } return token }) } } return directives } function parseCloudflareHeaders(content) { const lines = content.split(/\r?\n/) const result = [] let currentRoute = null for (const line of lines) { const trimmed = line.trim() if (!trimmed || trimmed.startsWith("#")) continue if (!line.startsWith(" ") && !line.startsWith("\t")) { // This is a route line, not an indented header currentRoute = trimmed continue } const colonIdx = trimmed.indexOf(":") if (colonIdx === -1) continue const name = trimmed.slice(0, colonIdx).trim().toLowerCase() const value = trimmed.slice(colonIdx + 1).trim() if (isCspHeader(name)) { if (currentRoute && !isGlobalRoute(currentRoute)) { warn( `Skipping route-specific CSP from non-global route: ${currentRoute}`, ) continue } result.push({ key: name, value }) } } return result } function pushCspHeaders(headers, rawHeaders) { for (const h of rawHeaders || []) { const key = (h.key || "").toLowerCase() if (isCspHeader(key)) { headers.push({ key: h.key, value: h.value }) } } } function safeReadFile(filePath, cwd) { if (!filePath) return null try { const resolved = fs.realpathSync(filePath) const root = path.resolve(cwd) const relative = path.relative(root, resolved) if (relative.startsWith("..") || path.isAbsolute(relative)) { warn(`Skipping file outside project: ${filePath}`) return null } const fd = fs.openSync(resolved, "r") try { const stats = fs.fstatSync(fd) if (!stats.isFile()) { warn(`Skipping non-file path: ${filePath}`) return null } if (stats.size > MAX_FILE_SIZE) { warn(`Skipping oversized file: ${filePath}`) return null } return fs.readFileSync(fd, "utf-8") } finally { fs.closeSync(fd) } } catch { return null } } function safeWriteFile(filePath, content, cwd) { try { const root = fs.realpathSync(path.resolve(cwd)) const target = path.resolve(filePath) const relative = path.relative(root, target) if (relative.startsWith("..") || path.isAbsolute(relative)) { warn(`Refusing to write outside project: ${filePath}`) return false } // Walk up the path and verify no existing component is a symlink let current = target while (current !== root && current !== path.dirname(current)) { if (fs.existsSync(current)) { const stat = fs.lstatSync(current) if (stat.isSymbolicLink()) { warn(`Refusing to write through symlink: ${current}`) return false } } current = path.dirname(current) } const dir = path.dirname(target) if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }) } // Re-verify directory path after mkdir to catch symlink races in parent dirs const resolvedDir = fs.realpathSync(dir) const dirRelative = path.relative(root, resolvedDir) if (dirRelative.startsWith("..") || path.isAbsolute(dirRelative)) { warn(`Refusing to write outside project: ${dir}`) return false } // Atomic write: temp file in same directory, then rename const tempFile = path.join( dir, `.appwarden-link-tmp-${process.hrtime.bigint().toString(36)}.json`, ) fs.writeFileSync(tempFile, content, { mode: 0o600 }) fs.renameSync(tempFile, target) // Final verification: ensure the written file is a regular file inside the // project. The nlink > 1 check specifically defends against a race where an // attacker creates a hard link to the temp file in the same directory between // writeFileSync and renameSync; it does not protect against a pre-existing // hard-linked target because rename atomically swaps inodes. const finalStat = fs.lstatSync(target) if (!finalStat.isFile() || finalStat.nlink > 1) { warn(`Refusing to write through link: ${target}`) return false } const finalResolved = fs.realpathSync(target) const finalRelative = path.relative(root, finalResolved) if (finalRelative.startsWith("..") || path.isAbsolute(finalRelative)) { warn(`Refusing to write outside project: ${target}`) return false } return true } catch (err) { warn(`Failed to write config: ${err.message}`) return false } } function extractLocalHeadersConfig(cwd, framework) { const headers = [] const nextConfig = findNextConfig(cwd) const nextConfigSource = safeReadFile(nextConfig, cwd) if (nextConfigSource) { headers.push(...extractHeadersFromNextConfig(nextConfigSource)) } const vercelJson = path.join(cwd, "vercel.json") const vercelJsonContent = safeReadFile(vercelJson, cwd) if (vercelJsonContent) { try { const vj = JSON.parse(vercelJsonContent) for (const rule of [...(vj.headers || []), ...(vj.routes || [])]) { const route = rule.source || rule.src || "" if (route && !isGlobalRoute(route)) { warn(`Skipping route-specific CSP from vercel.json rule: ${route}`) continue } // Legacy vercel.json routes[] may use an object map for headers let ruleHeaders = rule.headers if (ruleHeaders && !Array.isArray(ruleHeaders)) { ruleHeaders = Object.entries(ruleHeaders).map(([key, value]) => ({ key, value, })) } pushCspHeaders(headers, ruleHeaders) } } catch {} } const publicHeaders = path.join(cwd, "public", "_headers") const staticHeaders = path.join(cwd, "static", "_headers") const distHeaders = path.join(cwd, "dist", "_headers") const buildHeaders = path.join(cwd, "build", "_headers") const nextHeaders = path.join(cwd, ".next", "_headers") for (const hp of [ publicHeaders, staticHeaders, distHeaders, buildHeaders, nextHeaders, ]) { const content = safeReadFile(hp, cwd) if (content) { headers.push(...parseCloudflareHeaders(content)) } } if (!headers.length) return null const enforced = headers.find((h) => h.key.toLowerCase() === CSP_ENFORCED) const reportOnly = headers.find( (h) => h.key.toLowerCase() === CSP_REPORT_ONLY, ) if (enforced) { return { mode: "enforced", directives: parseCspHeaderValue(enforced.value) } } if (reportOnly) { return { mode: "report-only", directives: parseCspHeaderValue(reportOnly.value), } } return null } function isAllowedApiHostname(value) { try { const url = new URL(value) return ( url.protocol === "https:" && ALLOWED_API_HOSTNAMES.includes(url.hostname) ) } catch { return false } } function mapKeys(obj, transform) { if (!obj || typeof obj !== "object" || Array.isArray(obj)) { return obj } const result = Object.create(null) for (const [key, value] of Object.entries(obj)) { result[transform(key)] = value } return result } const normalizeRemoteConfigKeys = (obj) => mapKeys(obj, (key) => key.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase())) const kebabCaseDirectives = (obj) => mapKeys(obj, (key) => key.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()) function sanitizeRemoteConfig(data, depth = 0) { if (depth > 5) { warn("Remote config nested too deeply. Truncating.") return null } if (Array.isArray(data) && data.length === 0) { warn( "No Appwarden configuration was found for this domain. Are you sure the configuration exists and is committed to the domain configuration repository?", ) return Object.create(null) } if (!data || typeof data !== "object" || Array.isArray(data)) { warn("Remote config response was not a valid object. Ignoring.") return null } const safe = Object.create(null) for (const key of Object.keys(data)) { if (key === "__proto__" || key === "constructor" || key === "prototype") { continue } // Top-level keys must be explicitly allowlisted if (depth === 0 && !ALLOWED_REMOTE_CONFIG_KEYS.includes(key)) { warn(`Ignoring unexpected remote config key: ${key}`) continue } const val = data[key] // Reject untrusted API hostnames to prevent token exfiltration if ( key === "appwardenApiHostname" && typeof val === "string" && !isAllowedApiHostname(val) ) { warn(`Ignoring invalid appwardenApiHostname: ${val}`) continue } // Only accept primitive values, arrays of strings, and plain objects if (val === null || val === undefined) { safe[key] = val } else if ( typeof val === "string" || typeof val === "boolean" || typeof val === "number" ) { safe[key] = val } else if (Array.isArray(val) && val.every((v) => typeof v === "string")) { safe[key] = val } else if (typeof val === "object") { // Recursively sanitize nested objects (e.g. contentSecurityPolicy.directives) const nested = sanitizeRemoteConfig(val, depth + 1) if (nested) safe[key] = nested } } return safe } async function fetchRemoteConfig(apiToken, apiHostname, fqdn) { if (!apiToken || !fqdn) return null if ( typeof fqdn !== "string" || !fqdn.trim() || /[\/\\]/.test(fqdn) || fqdn.includes("..") ) { warn("Invalid FQDN provided. Skipping.") return null } const url = new URL("/v1/appwarden/config", apiHostname) url.searchParams.set("fqdn", fqdn) const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 10_000) const href = url.toString() try { const res = await fetch(href, { headers: { Authorization: apiToken }, signal: controller.signal, }) if (!res.ok) { if (res.status === 401) { throw new Error( "Authentication failed. Ensure your Appwarden API token is correct. Manage tokens at https://use.appwarden.io?to=settings/security", ) } warn(`fetch failed: ${href} ${res.status} ${res.statusText}`) return null } const contentLength = res.headers.get("content-length") if ( contentLength && parseInt(contentLength, 10) > MAX_REMOTE_RESPONSE_SIZE ) { warn(`Remote response too large: ${contentLength} bytes`) return null } let text = "" let bytes = 0 const reader = res.body.getReader() const decoder = new TextDecoder("utf-8") while (true) { const { done, value } = await reader.read() if (done) break bytes += value.length if (bytes > MAX_REMOTE_RESPONSE_SIZE) { warn("Remote response body too large") try { await reader.cancel() } catch {} return null } text += decoder.decode(value, { stream: true }) } text += decoder.decode() const data = JSON.parse(text) // The API returns { content: [{ url, options }] } — extract the config object let config = null if (data && Array.isArray(data.content)) { const entry = data.content.find( (item) => item.url === fqdn || item.url === `www.${fqdn}` || fqdn === `www.${item.url}`, ) config = entry?.options ?? null if ( config === null && entry && Array.isArray(entry.middleware) && entry.middleware.length === 0 ) { config = [] } } else if (data && typeof data === "object" && !Array.isArray(data)) { // Fallback: API returned a flat object directly config = data } if (config) { config = normalizeRemoteConfigKeys(config) // Normalize API-specific CSP keys into the middleware shape if (config.cspMode || config.cspDirectives) { config.contentSecurityPolicy = { mode: config.cspMode || "report-only", directives: kebabCaseDirectives(config.cspDirectives) || {}, } delete config.cspMode delete config.cspDirectives } } return sanitizeRemoteConfig(config) } catch (err) { if (err.message.startsWith("Authentication failed")) throw err warn(`fetch error: ${href} ${err.message}`) return null } finally { clearTimeout(timeout) } } function mergeConfigs(remote, localHeaders) { const merged = Object.create(null) if (remote && typeof remote === "object") { for (const key of Object.keys(remote)) { merged[key] = remote[key] } } if (localHeaders) { merged.contentSecurityPolicy = localHeaders } return merged } function checkGitignore(cwd) { if (process.env.APPWARDEN_SKIP_GITIGNORE_CHECK === "1") return true let current = path.resolve(cwd) const root = path.parse(current).root while (current !== root) { const gitignorePath = path.join(current, ".gitignore") try { const resolved = fs.realpathSync(gitignorePath) const resolvedDir = fs.realpathSync(current) if (path.dirname(resolved) !== resolvedDir) { warn(`Skipping suspicious gitignore symlink: ${resolved}`) return false } if (!resolved.endsWith(path.sep + ".gitignore")) { warn(`Skipping suspicious gitignore symlink: ${resolved}`) return false } const fd = fs.openSync(resolved, "r") try { const stats = fs.fstatSync(fd) if (!stats.isFile()) { warn(`Skipping non-file .gitignore: ${resolved}`) return false } if (stats.size > MAX_GITIGNORE_SIZE) { warn(`Skipping oversized .gitignore: ${resolved}`) return false } const content = fs.readFileSync(fd, "utf-8") if (content.includes(CONFIG_PATH) || content.includes(".appwarden/")) { return true } } finally { fs.closeSync(fd) } } catch (err) { if (err.code === "ENOENT") { // .gitignore doesn't exist in this directory; continue searching up } else { return false } } const parent = path.dirname(current) if (parent === current) break current = parent } return false } async function main() { if (process.env.APPWARDEN_SKIP_POSTBUILD === "1") { print("APPWARDEN_SKIP_POSTBUILD=1 detected, skipping.") process.exit(0) } const args = parseArgs() const debug = makeDebug(args.debug) const cwd = args.cwd const framework = detectFramework(cwd, args.framework) if (!framework) { warn("Could not detect framework. Use --framework=<name> to specify.") } else { print(`Detected framework: ${framework}`) } const apiToken = process.env.APPWARDEN_API_TOKEN || null const apiHostname = args.staging ? "https://staging-api.appwarden.io" : "https://api.appwarden.io" const fqdn = args.fqdn || process.env.APPWARDEN_FQDN || null if (args.staging) { print("Using staging API") } if (apiToken) { print(`Using provided Appwarden API token`) } else { print("APPWARDEN_API_TOKEN not set. Using local headers and defaults only.") } const localHeaders = extractLocalHeadersConfig(cwd, framework) if (localHeaders) { print("Found local CSP headers configuration.") } let remote try { remote = await fetchRemoteConfig(apiToken, apiHostname, fqdn) } catch (err) { warn(err.message) process.exit(1) } if (remote) { print("Fetched remote Appwarden configuration.") } const merged = mergeConfigs(remote, localHeaders) if (!merged.lockPageSlug && merged.lockPageSlug !== "") { merged.lockPageSlug = "" } const outDir = path.join(cwd, ".appwarden", "linked") const outPath = path.join(outDir, "middleware.json") const safeConfig = { ...merged } delete safeConfig.appwardenApiToken const validation = BuildOutputSchema.safeParse(safeConfig) if (!validation.success) { warn("Generated config failed validation:", validation.error.message) process.exit(1) } const writeOk = safeWriteFile( outPath, JSON.stringify(safeConfig, null, 2) + "\n", cwd, ) if (writeOk) { print(`Wrote merged configuration to ${outPath}`) debug(`Logging merged configuration ${JSON.stringify(safeConfig, null, 2)}`) } else { warn(`Failed to write merged configuration to ${outPath}`) process.exit(1) } if (!checkGitignore(cwd)) { warn(GITIGNORE_WARNING) } } main().catch((err) => { console.error("[appwarden] Fatal error:", err) process.exit(1) })