use anyhow::Result;
use crate::config::Config;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target {
Headers,
Vercel,
VercelMiddleware,
NetlifyEdge,
}
impl Target {
pub fn parse(s: &str) -> Result<Target> {
Ok(match s.trim().to_ascii_lowercase().as_str() {
"_headers" | "headers" | "netlify" | "cloudflare-pages" | "cf-pages" => Target::Headers,
"vercel" | "vercel.json" => Target::Vercel,
"vercel-middleware" | "middleware" => Target::VercelMiddleware,
"netlify-edge" | "edge" => Target::NetlifyEdge,
other => anyhow::bail!(
"unknown generate target {other:?} (expected one of: \
_headers, vercel, vercel-middleware, netlify-edge)"
),
})
}
pub fn filename(self) -> &'static str {
match self {
Target::Headers => "_headers",
Target::Vercel => "vercel.json",
Target::VercelMiddleware => "middleware.ts",
Target::NetlifyEdge => "edgeguard.ts",
}
}
}
pub fn generate(cfg: &Config, target: Target) -> String {
let headers = crate::proxy::security_headers(&cfg.headers);
match target {
Target::Headers => render_headers_file(&headers),
Target::Vercel => render_vercel_json(&headers),
Target::VercelMiddleware => render_vercel_middleware(&headers),
Target::NetlifyEdge => render_netlify_edge(&headers),
}
}
fn render_headers_file(headers: &[(&'static str, String)]) -> String {
let mut out = String::new();
out.push_str("# Generated by `edgeguard generate --target _headers`.\n");
out.push_str("# Response-hardening headers for Netlify and Cloudflare Pages (shared `_headers` format).\n");
out.push_str(
"# NOTE: a static `_headers` file can only ADD headers. EdgeGuard's cookie hardening,\n",
);
out.push_str(
"# leaky-header stripping (Server / X-Powered-By), auth, and rate limiting need a live\n",
);
out.push_str(
"# proxy or the Cloudflare Worker (see EdgeGuard's worker/). Review before committing.\n",
);
out.push_str("/*\n");
for (name, value) in headers {
out.push_str(" ");
out.push_str(name);
out.push_str(": ");
out.push_str(value);
out.push('\n');
}
out
}
fn render_vercel_json(headers: &[(&'static str, String)]) -> String {
let entries: Vec<serde_json::Value> = headers
.iter()
.map(|(k, v)| serde_json::json!({ "key": k, "value": v }))
.collect();
let doc = serde_json::json!({
"headers": [
{ "source": "/(.*)", "headers": entries }
]
});
let mut s = serde_json::to_string_pretty(&doc).unwrap_or_else(|_| "{}".to_string());
s.push('\n');
s
}
fn render_vercel_middleware(headers: &[(&'static str, String)]) -> String {
let mut out = String::new();
out.push_str("// Generated by `edgeguard generate --target vercel-middleware`.\n");
out.push_str("// Vercel Edge Middleware: applies EdgeGuard's response-hardening headers to every response.\n");
out.push_str("// Cookie hardening, leaky-header stripping, auth, and rate limiting need the full proxy\n");
out.push_str("// or EdgeGuard's Cloudflare Worker (worker/). Review before committing.\n");
out.push_str("import { NextResponse } from \"next/server\";\n\n");
out.push_str("export const config = { matcher: \"/:path*\" };\n\n");
out.push_str("const SECURITY_HEADERS: Record<string, string> = {\n");
out.push_str(&js_object_entries(headers, " "));
out.push_str("};\n\n");
out.push_str("export function middleware() {\n");
out.push_str(" const res = NextResponse.next();\n");
out.push_str(" for (const [name, value] of Object.entries(SECURITY_HEADERS)) {\n");
out.push_str(" res.headers.set(name, value);\n");
out.push_str(" }\n");
out.push_str(" return res;\n");
out.push_str("}\n");
out
}
fn render_netlify_edge(headers: &[(&'static str, String)]) -> String {
let mut out = String::new();
out.push_str("// Generated by `edgeguard generate --target netlify-edge`.\n");
out.push_str("// Netlify Edge Function: adds EdgeGuard's response-hardening headers to every response.\n");
out.push_str("// Wire it up in netlify.toml:\n");
out.push_str("// [[edge_functions]]\n");
out.push_str("// path = \"/*\"\n");
out.push_str("// function = \"edgeguard\"\n");
out.push_str("// Cookie hardening, leaky-header stripping, auth, and rate limiting need the full proxy\n");
out.push_str("// or EdgeGuard's Cloudflare Worker (worker/). Review before committing.\n");
out.push_str("import type { Context } from \"https://edge.netlify.com\";\n\n");
out.push_str("const SECURITY_HEADERS: Record<string, string> = {\n");
out.push_str(&js_object_entries(headers, " "));
out.push_str("};\n\n");
out.push_str(
"export default async (_request: Request, context: Context): Promise<Response> => {\n",
);
out.push_str(" const response = await context.next();\n");
out.push_str(" for (const [name, value] of Object.entries(SECURITY_HEADERS)) {\n");
out.push_str(" response.headers.set(name, value);\n");
out.push_str(" }\n");
out.push_str(" return response;\n");
out.push_str("};\n");
out
}
fn js_object_entries(headers: &[(&'static str, String)], indent: &str) -> String {
let mut out = String::new();
for (name, value) in headers {
out.push_str(indent);
out.push_str(&js_string(name));
out.push_str(": ");
out.push_str(&js_string(value));
out.push_str(",\n");
}
out
}
fn js_string(s: &str) -> String {
serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::HeadersCfg;
fn default_cfg() -> Config {
Config::default()
}
#[test]
fn target_parse_aliases_and_errors() {
assert_eq!(Target::parse("_headers").unwrap(), Target::Headers);
assert_eq!(Target::parse("NETLIFY").unwrap(), Target::Headers);
assert_eq!(Target::parse("cloudflare-pages").unwrap(), Target::Headers);
assert_eq!(Target::parse(" Vercel ").unwrap(), Target::Vercel);
assert_eq!(
Target::parse("vercel-middleware").unwrap(),
Target::VercelMiddleware
);
assert_eq!(Target::parse("netlify-edge").unwrap(), Target::NetlifyEdge);
assert!(Target::parse("nginx").is_err());
}
#[test]
fn headers_file_lists_every_security_header() {
let cfg = default_cfg();
let out = generate(&cfg, Target::Headers);
assert!(out.contains("/*\n"), "missing path pattern:\n{out}");
for (name, value) in crate::proxy::security_headers(&cfg.headers) {
assert!(
out.contains(&format!(" {name}: {value}\n")),
"missing `{name}: {value}` in:\n{out}"
);
}
assert!(out.contains(" X-Content-Type-Options: nosniff\n"));
assert!(out.contains(" Content-Security-Policy: default-src 'self'\n"));
}
#[test]
fn headers_file_honors_report_only_and_toggles() {
let cfg = Config {
headers: HeadersCfg {
hsts: false,
csp_report_only: true,
..HeadersCfg::default()
},
..Config::default()
};
let out = generate(&cfg, Target::Headers);
assert!(out.contains("Content-Security-Policy-Report-Only:"));
assert!(!out.contains("\n Content-Security-Policy:"));
assert!(!out.contains("Strict-Transport-Security"));
}
#[test]
fn vercel_json_is_valid_json_with_all_headers() {
let cfg = default_cfg();
let out = generate(&cfg, Target::Vercel);
let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
let rule = &parsed["headers"][0];
assert_eq!(rule["source"], "/(.*)");
let arr = rule["headers"].as_array().expect("headers array");
let mut got = std::collections::HashMap::new();
for e in arr {
got.insert(
e["key"].as_str().unwrap().to_string(),
e["value"].as_str().unwrap().to_string(),
);
}
for (name, value) in crate::proxy::security_headers(&cfg.headers) {
assert_eq!(got.get(name).map(String::as_str), Some(value.as_str()));
}
}
#[test]
fn middleware_and_edge_snippets_escape_csp_quotes() {
let cfg = default_cfg();
for target in [Target::VercelMiddleware, Target::NetlifyEdge] {
let out = generate(&cfg, target);
assert!(
out.contains(r#""Content-Security-Policy": "default-src 'self'""#),
"CSP not emitted as a clean literal for {target:?}:\n{out}"
);
assert!(out.contains("Object.entries(SECURITY_HEADERS)"));
assert!(out.contains(r#""X-Content-Type-Options": "nosniff""#));
}
}
}