Skip to main content

edgeguard/
generate.rs

1//! Static & edge host config generator (Phase 5 / v2.5).
2//!
3//! Static/edge hosts (Netlify, Cloudflare Pages, Vercel) can't run EdgeGuard's long-lived proxy
4//! process, but they *can* apply EdgeGuard's **response-hardening** headers at their own edge.
5//! This module renders the `[headers]` policy into the native config each platform understands —
6//! a `_headers` file or an edge-middleware snippet — so a project hosted there gets the same
7//! security headers the proxy would inject.
8//!
9//! The header set comes from [`crate::proxy::security_headers`], the *same* source of truth the
10//! live proxy uses, so generated output can't drift from runtime behavior.
11//!
12//! **Out of scope for a static file:** EdgeGuard's cookie hardening and leaky-header stripping
13//! act on the upstream's real response and can't be expressed as "always add this header"; and
14//! a `_headers` file can't authenticate or rate-limit. For the full request+response pipeline
15//! (auth, cookie/`Set-Cookie` rewriting, header stripping) at the edge, use the Rust→WASM
16//! Cloudflare Worker in `worker/`.
17
18use anyhow::Result;
19
20use crate::config::Config;
21
22/// A static-host / edge target the generator can emit config for.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Target {
25    /// A `_headers` file — the format shared by **Netlify** and **Cloudflare Pages**.
26    Headers,
27    /// A `vercel.json` `headers` array (Vercel's static header config).
28    Vercel,
29    /// Vercel **Edge Middleware** (`middleware.ts`).
30    VercelMiddleware,
31    /// A **Netlify Edge Function** (Deno/TypeScript).
32    NetlifyEdge,
33}
34
35impl Target {
36    /// Parse a `--target` value (case-insensitive), accepting a few friendly aliases.
37    pub fn parse(s: &str) -> Result<Target> {
38        Ok(match s.trim().to_ascii_lowercase().as_str() {
39            "_headers" | "headers" | "netlify" | "cloudflare-pages" | "cf-pages" => Target::Headers,
40            "vercel" | "vercel.json" => Target::Vercel,
41            "vercel-middleware" | "middleware" => Target::VercelMiddleware,
42            "netlify-edge" | "edge" => Target::NetlifyEdge,
43            other => anyhow::bail!(
44                "unknown generate target {other:?} (expected one of: \
45                 _headers, vercel, vercel-middleware, netlify-edge)"
46            ),
47        })
48    }
49
50    /// The conventional output filename for this target (used when `--out` is a directory or for
51    /// the "wrote ..." hint).
52    pub fn filename(self) -> &'static str {
53        match self {
54            Target::Headers => "_headers",
55            Target::Vercel => "vercel.json",
56            Target::VercelMiddleware => "middleware.ts",
57            Target::NetlifyEdge => "edgeguard.ts",
58        }
59    }
60}
61
62/// Render the configured `[headers]` policy as the native config for `target`.
63pub fn generate(cfg: &Config, target: Target) -> String {
64    let headers = crate::proxy::security_headers(&cfg.headers);
65    match target {
66        Target::Headers => render_headers_file(&headers),
67        Target::Vercel => render_vercel_json(&headers),
68        Target::VercelMiddleware => render_vercel_middleware(&headers),
69        Target::NetlifyEdge => render_netlify_edge(&headers),
70    }
71}
72
73/// A `_headers` file (Netlify + Cloudflare Pages share this format). Path pattern `/*` then one
74/// `Name: Value` line per header, indented two spaces.
75fn render_headers_file(headers: &[(&'static str, String)]) -> String {
76    let mut out = String::new();
77    out.push_str("# Generated by `edgeguard generate --target _headers`.\n");
78    out.push_str("# Response-hardening headers for Netlify and Cloudflare Pages (shared `_headers` format).\n");
79    out.push_str(
80        "# NOTE: a static `_headers` file can only ADD headers. EdgeGuard's cookie hardening,\n",
81    );
82    out.push_str(
83        "# leaky-header stripping (Server / X-Powered-By), auth, and rate limiting need a live\n",
84    );
85    out.push_str(
86        "# proxy or the Cloudflare Worker (see EdgeGuard's worker/). Review before committing.\n",
87    );
88    out.push_str("/*\n");
89    for (name, value) in headers {
90        out.push_str("  ");
91        out.push_str(name);
92        out.push_str(": ");
93        out.push_str(value);
94        out.push('\n');
95    }
96    out
97}
98
99/// A `vercel.json` with a single `headers` rule matching every path. Strict JSON (no comments),
100/// built via `serde_json` so values are correctly escaped.
101fn render_vercel_json(headers: &[(&'static str, String)]) -> String {
102    let entries: Vec<serde_json::Value> = headers
103        .iter()
104        .map(|(k, v)| serde_json::json!({ "key": k, "value": v }))
105        .collect();
106    let doc = serde_json::json!({
107        "headers": [
108            { "source": "/(.*)", "headers": entries }
109        ]
110    });
111    // Serializing a plain `Value` is infallible; fall back to an empty object out of caution.
112    let mut s = serde_json::to_string_pretty(&doc).unwrap_or_else(|_| "{}".to_string());
113    s.push('\n');
114    s
115}
116
117/// Vercel Edge Middleware (`middleware.ts`): set the hardening headers on every response.
118fn render_vercel_middleware(headers: &[(&'static str, String)]) -> String {
119    let mut out = String::new();
120    out.push_str("// Generated by `edgeguard generate --target vercel-middleware`.\n");
121    out.push_str("// Vercel Edge Middleware: applies EdgeGuard's response-hardening headers to every response.\n");
122    out.push_str("// Cookie hardening, leaky-header stripping, auth, and rate limiting need the full proxy\n");
123    out.push_str("// or EdgeGuard's Cloudflare Worker (worker/). Review before committing.\n");
124    out.push_str("import { NextResponse } from \"next/server\";\n\n");
125    out.push_str("export const config = { matcher: \"/:path*\" };\n\n");
126    out.push_str("const SECURITY_HEADERS: Record<string, string> = {\n");
127    out.push_str(&js_object_entries(headers, "  "));
128    out.push_str("};\n\n");
129    out.push_str("export function middleware() {\n");
130    out.push_str("  const res = NextResponse.next();\n");
131    out.push_str("  for (const [name, value] of Object.entries(SECURITY_HEADERS)) {\n");
132    out.push_str("    res.headers.set(name, value);\n");
133    out.push_str("  }\n");
134    out.push_str("  return res;\n");
135    out.push_str("}\n");
136    out
137}
138
139/// A Netlify Edge Function (Deno/TypeScript) that hardens the downstream response.
140fn render_netlify_edge(headers: &[(&'static str, String)]) -> String {
141    let mut out = String::new();
142    out.push_str("// Generated by `edgeguard generate --target netlify-edge`.\n");
143    out.push_str("// Netlify Edge Function: adds EdgeGuard's response-hardening headers to every response.\n");
144    out.push_str("// Wire it up in netlify.toml:\n");
145    out.push_str("//   [[edge_functions]]\n");
146    out.push_str("//   path = \"/*\"\n");
147    out.push_str("//   function = \"edgeguard\"\n");
148    out.push_str("// Cookie hardening, leaky-header stripping, auth, and rate limiting need the full proxy\n");
149    out.push_str("// or EdgeGuard's Cloudflare Worker (worker/). Review before committing.\n");
150    out.push_str("import type { Context } from \"https://edge.netlify.com\";\n\n");
151    out.push_str("const SECURITY_HEADERS: Record<string, string> = {\n");
152    out.push_str(&js_object_entries(headers, "  "));
153    out.push_str("};\n\n");
154    out.push_str(
155        "export default async (_request: Request, context: Context): Promise<Response> => {\n",
156    );
157    out.push_str("  const response = await context.next();\n");
158    out.push_str("  for (const [name, value] of Object.entries(SECURITY_HEADERS)) {\n");
159    out.push_str("    response.headers.set(name, value);\n");
160    out.push_str("  }\n");
161    out.push_str("  return response;\n");
162    out.push_str("};\n");
163    out
164}
165
166/// Render `headers` as the body of a JS/TS object literal, one `"key": "value",` per line at the
167/// given `indent`. Keys and values are emitted as JSON string literals (via `serde_json`), which
168/// are also valid JS/TS string literals — so any quotes in a CSP value are escaped correctly.
169fn js_object_entries(headers: &[(&'static str, String)], indent: &str) -> String {
170    let mut out = String::new();
171    for (name, value) in headers {
172        out.push_str(indent);
173        out.push_str(&js_string(name));
174        out.push_str(": ");
175        out.push_str(&js_string(value));
176        out.push_str(",\n");
177    }
178    out
179}
180
181/// A JSON-encoded (hence safely double-quoted and escaped) string literal.
182fn js_string(s: &str) -> String {
183    serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string())
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use crate::config::HeadersCfg;
190
191    fn default_cfg() -> Config {
192        Config::default()
193    }
194
195    #[test]
196    fn target_parse_aliases_and_errors() {
197        assert_eq!(Target::parse("_headers").unwrap(), Target::Headers);
198        assert_eq!(Target::parse("NETLIFY").unwrap(), Target::Headers);
199        assert_eq!(Target::parse("cloudflare-pages").unwrap(), Target::Headers);
200        assert_eq!(Target::parse(" Vercel ").unwrap(), Target::Vercel);
201        assert_eq!(
202            Target::parse("vercel-middleware").unwrap(),
203            Target::VercelMiddleware
204        );
205        assert_eq!(Target::parse("netlify-edge").unwrap(), Target::NetlifyEdge);
206        assert!(Target::parse("nginx").is_err());
207    }
208
209    #[test]
210    fn headers_file_lists_every_security_header() {
211        let cfg = default_cfg();
212        let out = generate(&cfg, Target::Headers);
213        assert!(out.contains("/*\n"), "missing path pattern:\n{out}");
214        // Every header from the shared source of truth must appear with its exact value.
215        for (name, value) in crate::proxy::security_headers(&cfg.headers) {
216            assert!(
217                out.contains(&format!("  {name}: {value}\n")),
218                "missing `{name}: {value}` in:\n{out}"
219            );
220        }
221        // Spot-check a couple of concrete defaults.
222        assert!(out.contains("  X-Content-Type-Options: nosniff\n"));
223        assert!(out.contains("  Content-Security-Policy: default-src 'self'\n"));
224    }
225
226    #[test]
227    fn headers_file_honors_report_only_and_toggles() {
228        let cfg = Config {
229            headers: HeadersCfg {
230                hsts: false,
231                csp_report_only: true,
232                ..HeadersCfg::default()
233            },
234            ..Config::default()
235        };
236        let out = generate(&cfg, Target::Headers);
237        assert!(out.contains("Content-Security-Policy-Report-Only:"));
238        assert!(!out.contains("\n  Content-Security-Policy:"));
239        assert!(!out.contains("Strict-Transport-Security"));
240    }
241
242    #[test]
243    fn vercel_json_is_valid_json_with_all_headers() {
244        let cfg = default_cfg();
245        let out = generate(&cfg, Target::Vercel);
246        let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
247        let rule = &parsed["headers"][0];
248        assert_eq!(rule["source"], "/(.*)");
249        let arr = rule["headers"].as_array().expect("headers array");
250        // Reconstruct name->value and check it matches the shared source of truth exactly.
251        let mut got = std::collections::HashMap::new();
252        for e in arr {
253            got.insert(
254                e["key"].as_str().unwrap().to_string(),
255                e["value"].as_str().unwrap().to_string(),
256            );
257        }
258        for (name, value) in crate::proxy::security_headers(&cfg.headers) {
259            assert_eq!(got.get(name).map(String::as_str), Some(value.as_str()));
260        }
261    }
262
263    #[test]
264    fn middleware_and_edge_snippets_escape_csp_quotes() {
265        // The default CSP `default-src 'self'` carries single quotes; emitting it inside a JS/TS
266        // object literal must keep it intact (we use double-quoted JSON string literals).
267        let cfg = default_cfg();
268        for target in [Target::VercelMiddleware, Target::NetlifyEdge] {
269            let out = generate(&cfg, target);
270            assert!(
271                out.contains(r#""Content-Security-Policy": "default-src 'self'""#),
272                "CSP not emitted as a clean literal for {target:?}:\n{out}"
273            );
274            // Sanity: the snippet sets headers in a loop and is syntactically plausible.
275            assert!(out.contains("Object.entries(SECURITY_HEADERS)"));
276            assert!(out.contains(r#""X-Content-Type-Options": "nosniff""#));
277        }
278    }
279}