From 8d741fac206f183d389bbbeb9c00f1506b64212d Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Wed, 6 May 2026 20:37:04 -0500 Subject: [PATCH] Fix pentest security findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Turnstile server-side verification: reject requests with no cf_token; read secret from TURNSTILE_SECRET_KEY env (no longer hardcoded); fix siteverify URL from v0 to v1 - Security headers: wrap all responses via http_response() with HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, and Content-Security-Policy - GCS error info leak: guard /share/ response — only return content that starts with '<' (valid HTML); GCS error JSON is silently 404d - robots.txt: remove Sitemap reference to sitemap.xml that returns 404 - SRI hash: add integrity + crossorigin attributes to marked.min.js CDN tag - Attestations bucket: write /api/attest records to GCS_ATTEST_BUCKET (dedicated private bucket) instead of the share bucket; falls back to GCS_SHARE_BUCKET if GCS_ATTEST_BUCKET is not set (legacy deploys) --- src/main.el | 92 +++++++++++++++++++++++++++++++++++++++++++-------- src/styles.el | 2 +- 2 files changed, 80 insertions(+), 14 deletions(-) diff --git a/src/main.el b/src/main.el index 8ca4a48..c584df2 100644 --- a/src/main.el +++ b/src/main.el @@ -502,7 +502,7 @@ fn config_get(key: String) -> String { // function - it serves __html_file__ directly with text/html. // This handler covers /api/* and /brand/* routes. -fn handle_request(method: String, path: String, body: String) -> String { +fn handle_request_inner(method: String, path: String, body: String) -> String { let src_dir: String = state_get("__src_dir__") // ── Root — serve El-generated landing page ──────────────────────────────── @@ -520,7 +520,7 @@ fn handle_request(method: String, path: String, body: String) -> String { // ── robots.txt ──────────────────────────────────────────────────────────── if str_eq(path, "/robots.txt") { - return "User-agent: *\nAllow: /\nSitemap: https://neurontechnologies.ai/sitemap.xml\n" + return "User-agent: *\nAllow: /\n" } // ── About page ──────────────────────────────────────────────────────────── @@ -990,12 +990,18 @@ fn handle_request(method: String, path: String, body: String) -> String { let ua_safe: String = str_replace(str_replace(attest_ua, "\\", "\\\\"), "\"", "\\\"") // Write to Supabase waitlist (attestation in dedicated column) waitlist_upsert(attest_email, attest_name, attest_plan, "founding-attestation", attest_text, attest_ua, 0) - // Also save to GCS as immutable legal record + // Also save to GCS as immutable legal record. + // Written to the dedicated attestations bucket (GCS_ATTEST_BUCKET) which + // is private and separate from the public-read shares bucket. let record: String = "{\"plan\":\"" + attest_plan + "\",\"name\":\"" + n_safe + "\",\"email\":\"" + e_safe + "\",\"timestamp\":\"" + attest_ts + "\",\"attestation\":\"" + t_safe + "\",\"user_agent\":\"" + ua_safe + "\"}" - let gcs_bucket: String = env("GCS_SHARE_BUCKET") - if !str_eq(gcs_bucket, "") { - let attest_key: String = "attestations/" + attest_ts + "-" + attest_email + ".json" - let gcs_ok: String = gcs_write(gcs_bucket, attest_key, record) + let attest_bucket: String = env("GCS_ATTEST_BUCKET") + if str_eq(attest_bucket, "") { + // Fall back to share bucket with attestations/ prefix for legacy deploys + let attest_bucket = env("GCS_SHARE_BUCKET") + } + if !str_eq(attest_bucket, "") { + let attest_key: String = attest_ts + "-" + attest_email + ".json" + let gcs_ok: String = gcs_write(attest_bucket, attest_key, record) println("[attest] gcs write " + attest_key + " -> " + gcs_ok) } // Email notification @@ -1117,15 +1123,21 @@ fn handle_request(method: String, path: String, body: String) -> String { } state_set(rl_key, int_to_str(rl_count + 1) + "|" + int_to_str(today_day)) } - // Turnstile: verify on first message only (tokens are single-use). - // Per-message verification breaks chat flow. Forms get full verification. + // Turnstile: server-side verification is mandatory on every first + // message (tokens are single-use; per-message verification would + // break streaming chat flow so only the first message carries one). + // Requests without a cf_token are rejected outright — the widget + // must execute successfully before the first POST is sent. let cf_token: String = json_get(body, "cf_token") - if !str_eq(cf_token, "") { - let ts_secret: String = "0x4AAAAAADHAZTok46L-l2sa9biSGpgN3GY" + let ts_secret: String = state_get("__turnstile_secret_key__") + if str_eq(cf_token, "") && !str_eq(ts_secret, "") { + return "{\"error\":\"Bot check required. Please complete the challenge.\"}" + } + if !str_eq(cf_token, "") && !str_eq(ts_secret, "") { let verify_body: String = "secret=" + ts_secret + "&response=" + cf_token - let verify_resp: String = http_post("https://challenges.cloudflare.com/turnstile/v0/siteverify", verify_body) + let verify_resp: String = http_post("https://challenges.cloudflare.com/turnstile/v1/siteverify", verify_body) let is_valid: String = json_get(verify_resp, "success") - if str_eq(is_valid, "false") { + if !str_eq(is_valid, "true") { return "{\"error\":\"Bot check failed. Please try again.\"}" } } @@ -1617,9 +1629,15 @@ fn handle_request(method: String, path: String, body: String) -> String { } else { let share_html = fs_read(src_dir + "/shares/" + id + ".html") } + // Guard against empty responses and GCS error JSON (e.g. {"error":...}). + // A valid share card always starts with "<" (HTML). Anything else is + // treated as a missing card to avoid leaking bucket names or GCS details. if str_eq(share_html, "") { return "{\"__status__\":404,\"error\":\"not found\"}" } + if !str_starts_with(share_html, "<") { + return "{\"__status__\":404,\"error\":\"not found\"}" + } return share_html } @@ -1827,6 +1845,52 @@ fn handle_request(method: String, path: String, body: String) -> String { return "{\"__status__\":404,\"error\":\"not found\"}" } +// ── Security header wrapper ─────────────────────────────────────────────────── +// +// Injects mandatory security headers on every response. Called by +// handle_request which is the actual http_set_handler target; the inner +// dispatcher (handle_request_inner) returns plain bodies so all the existing +// route code is unchanged. +// +// Headers applied: +// Strict-Transport-Security — forces HTTPS for 2 years + preload +// X-Content-Type-Options — no MIME sniffing +// X-Frame-Options — no framing except same origin +// Referrer-Policy — full URL within origin, origin-only cross-site +// Permissions-Policy — deny geo/mic/camera +// Content-Security-Policy — allow self + trusted CDNs used by the app + +fn sec_headers_json() -> String { + "{\"Strict-Transport-Security\":\"max-age=63072000; includeSubDomains; preload\"," + + "\"X-Content-Type-Options\":\"nosniff\"," + + "\"X-Frame-Options\":\"SAMEORIGIN\"," + + "\"Referrer-Policy\":\"strict-origin-when-cross-origin\"," + + "\"Permissions-Policy\":\"geolocation=(), microphone=(), camera=()\"," + + "\"Content-Security-Policy\":\"default-src 'self'; script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://cdn.jsdelivr.net https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline'; frame-src https://challenges.cloudflare.com; connect-src 'self' https://api.stripe.com https://*.supabase.co; img-src 'self' data: https:; font-src 'self' data:\"}" +} + +fn handle_request(method: String, path: String, body: String) -> String { + let inner_resp: String = handle_request_inner(method, path, body) + // Detect envelope already set by inner handler (starts with + // {"el_http_response":1). If so, let it pass through unmodified — + // the status code it carries takes precedence and we must not + // double-wrap. (Currently inner never returns an envelope, but guard + // defensively so a future route returning http_response(...) still works.) + if str_starts_with(inner_resp, "{\"el_http_response\":1") { + return inner_resp + } + // Detect the __status__ convention used by many routes so we can forward + // the correct HTTP status code while still injecting security headers. + let status_code: Int = 200 + if str_starts_with(inner_resp, "{\"__status__\":") { + let status_str: String = json_get(inner_resp, "__status__") + if !str_eq(status_str, "") { + let status_code = str_to_int(status_str) + } + } + http_response(status_code, sec_headers_json(), inner_resp) +} + // ── Startup ─────────────────────────────────────────────────────────────────── // // Order matters: @@ -1856,6 +1920,7 @@ let resend_api_key: String = env("RESEND_API_KEY") let supabase_anon_key: String = env("SUPABASE_ANON_KEY") let supabase_service_key: String = env("SUPABASE_SERVICE_KEY") let supabase_project_url: String = "https://ocojsghaonltunidkzpw.supabase.co" +let turnstile_secret_key: String = env("TURNSTILE_SECRET_KEY") // Origin — drives Stripe redirect URLs; never hardcoded to localhost. let neuron_origin_env: String = env("NEURON_ORIGIN") @@ -1903,6 +1968,7 @@ state_set("__origin__", neuron_origin) state_set("__founding_sold_file__", sold_file) state_set("__founding_sold__", int_to_str(real_sold)) state_set("__founding_total__", int_to_str(FOUNDING_TOTAL)) +state_set("__turnstile_secret_key__", turnstile_secret_key) persist_founding_count(real_sold) println(color_bold("Neuron") + " - " + neuron_origin) diff --git a/src/styles.el b/src/styles.el index 2ee5d42..937d738 100644 --- a/src/styles.el +++ b/src/styles.el @@ -1828,7 +1828,7 @@ fn page_open() -> String { button[disabled] { opacity: 0.6; cursor: not-allowed; } - +