1use anyhow::Result;
19
20use crate::config::Config;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Target {
25 Headers,
27 Vercel,
29 VercelMiddleware,
31 NetlifyEdge,
33}
34
35impl Target {
36 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 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
62pub 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
73fn 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
99fn 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 let mut s = serde_json::to_string_pretty(&doc).unwrap_or_else(|_| "{}".to_string());
113 s.push('\n');
114 s
115}
116
117fn 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
139fn 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
166fn 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
181fn 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 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 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 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 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 assert!(out.contains("Object.entries(SECURITY_HEADERS)"));
276 assert!(out.contains(r#""X-Content-Type-Options": "nosniff""#));
277 }
278 }
279}