Production security audit
Audited against this repository on 2026-07-30. Findings are evidence-based from current code; update this file when remediations land.
Never paste secrets here — no webhook URLs, API keys, tokens, or customer PII. Reference variable names and file paths only.
Verdict
Not production-ready for multi-tenant / CRM-backed lead capture without hardening. Public GEO/MCP exposure is partly intentional (see ../decisions/0003-lead-capture-via-mcp-and-openapi.md), but multi-tenant routing, Apps Script auth, rate-limit bypass, and PII logging are concrete abuse paths.
Attack surface (summary)
| Surface |
Path |
Auth |
Rate limit |
CORS |
| REST lead capture |
POST /api/leads/submit |
None (X-API-Key is not validated) |
In-memory, 10/min/IP |
* |
| MCP Streamable HTTP |
/api/mcp/sse, /api/mcp/mcp |
None |
None |
* |
| On-site contact |
POST /api/contact |
None |
In-memory, 5/min/IP |
Same-origin form |
| OpenAPI docs |
/docs/openapi.json, /api/openapi.json |
None |
N/A |
* |
| CRM webhook (Apps Script) |
CRM_WEBHOOK_URL target |
URL-as-secret only |
N/A |
N/A |
Related integration notes: ../integrations/lead-capture.md, ../integrations/mcp-server.md. Multi-tenant ADR: ../decisions/0008-multi-tenant-lead-routing-and-rate-limiting.md.
Critical
1. Google Apps Script webhook has no authentication
|
|
| Files |
scripts/google-apps-script/Code.gs (approx. L37–86), scripts/google-apps-script/SETUP.md |
| Evidence |
doGet / doPost accept any caller; no shared secret, signature, or token check. Setup requires Who has access: Anyone. |
| Impact |
Anyone holding the Web App URL (logs, leaked env, error surfaces, referrers) can append arbitrary rows — sheet spam, data pollution, formula payloads. |
| Fix |
Require a header secret (e.g. X-Webhook-Secret) checked in doPost; rotate the URL on leak; optionally restrict by known server IPs if hosting allows. |
High
2. Multi-tenant client_id is attacker-controlled (tenant spoofing)
|
|
| Files |
app/api/leads/submit/route.ts (approx. L76–82), lib/leads.ts (resolveClientWebhookUrl, submitConsultationLead), lib/mcp-server.ts, lib/openapi-spec.ts |
| Evidence |
client_id comes from body, X-Client-ID, or X-API-Key with no proof of tenancy. That ID maps to tenant webhooks via CLIENT_WEBHOOK_MAP / CLIENT_WEBHOOK_<ID> / CRM_WEBHOOK_URL. |
| Impact |
Knowing or guessing a tenant ID routes junk into that client’s CRM/Sheet. Unknown IDs fall back to the default webhook (spam the owner). OpenAPI documents the client_id pattern. |
| Fix |
Bind routing to a server-side API key → tenant map. Never accept raw public client_id as routing authority. |
3. X-API-Key is not authentication — it is treated as client_id
|
|
| Files |
app/api/leads/submit/route.ts (CORS headers + header extraction) |
| Evidence |
x-client-id and x-api-key are OR’d into the same clientId string. Neither is validated against a secret store. CORS advertises Authorization / X-API-Key. |
| Impact |
False sense of security; “keys” become tenant identifiers and may leak in browser/agent configs. |
| Fix |
Implement real API-key auth (constant-time compare against hashed secrets) or remove the header until auth exists. |
|
|
| Files |
lib/mcp-server.ts, app/api/mcp/[transport]/route.ts, ../integrations/mcp-server.md |
| Evidence |
CORS Access-Control-Allow-Origin: *; no auth; no checkRateLimit. Advertised in public/llms.txt, .well-known/llms.txt, and JSON-LD. |
| Impact |
Unlimited webhook/Sheet writes via MCP, bypassing the 10/min limit on /api/leads/submit. |
| Fix |
Shared rate limiter (Redis/Upstash), optional MCP auth for non-demo use, bot/abuse controls. Keep fully public only if product requires it and residual spam risk is accepted. |
|
|
| Files |
lib/rate-limit.ts (getClientIp) |
| Evidence |
Uses leftmost x-forwarded-for, then x-real-ip, then cf-connecting-ip. |
| Impact |
Attackers can rotate spoofed IPs and evade the 10/min and 5/min limits unless the platform overwrites these headers. |
| Fix |
Trust only the platform’s canonical client IP (after trusted proxy rewrite). Never trust raw leftmost XFF without a trusted hop count. |
6. In-memory rate limiter is ineffective on multi-instance / serverless
|
|
| Files |
lib/rate-limit.ts (process-local Map), ADR ../decisions/0008-multi-tenant-lead-routing-and-rate-limiting.md |
| Evidence |
Tracker is in-process; resets per instance / cold start. ADR already notes Redis as a follow-up. |
| Impact |
Effective limit scales with instance count — weak anti-abuse in production. |
| Fix |
Distributed limiter (Redis / Upstash / edge). |
Medium
7. Webhook URL / upstream detail leak into server errors and logs
|
|
| Files |
lib/leads.ts (network failure path embeds webhookUrl in LeadWebhookError), app/api/leads/submit/route.ts (console.error of full error) |
| Evidence |
HTTP JSON responses to clients are sanitized (good), but logs/APM retain the secret Web App URL. |
| Note |
Webhook targets are env-configured — classic user-driven SSRF from the request body is not present. Risk is credential disclosure via logs. |
| Fix |
Log status/error code only; never log full webhook URL or upstream body; redact in error constructors. |
|
|
| Files |
lib/leads.ts (unconfigured webhook → console.info of full payload), app/api/contact/route.ts (console.info("[contact] Lead received", lead)) |
| Impact |
Name, email/phone, notes in host logs (retention, access, compliance). |
| Fix |
Log lead_id + non-PII metadata only; redact contact fields. |
| Field |
Current limit |
client_name |
min 2, no max |
budget_range_usd (string) |
min 1, no max |
client_email_or_phone |
min 5, no max |
contact name / email / scope |
min only; no max |
requirements_notes |
max 5000 (correct) |
|
|
| Files |
lib/leads.ts, app/api/contact/route.ts |
| Impact |
Large JSON bodies → memory/CPU, huge Sheet cells, log bloat. |
| Fix |
Add max lengths (e.g. name 200, contact 100, budget string 100, scope 5000); reject oversized bodies early. |
|
|
| Files |
scripts/google-apps-script/Code.gs (appendRow) |
| Evidence |
User strings written without neutralizing leading =, +, -, @. |
| Impact |
Malicious requirements_notes / names can become formulas (IMPORTDATA, etc.) when opened in Sheets. |
| Fix |
Prefix dangerous values with ' or escape before append. |
|
|
| Files |
next.config.ts (OpenAPI CORS only); no middleware.ts |
| Evidence |
Missing CSP, HSTS, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, frame-ancestors / X-Frame-Options. |
| Impact |
Weaker XSS / clickjacking / MIME defenses for the marketing site. |
| Fix |
Add baseline headers in next.config.ts or middleware (tighten CSP carefully around JSON-LD). |
|
|
| Files |
app/api/contact/route.ts |
| Evidence |
Resend path is a stub; success returned after logging only. |
| Impact |
Silent lead loss; users believe submission succeeded. |
| Fix |
Wire delivery, or return an explicit “not configured” / disable the form in production until ready. |
Low
13. Public OpenAPI + llms.txt expand attack surface (by design)
|
|
| Files |
lib/openapi-spec.ts, OpenAPI routes, public/llms.txt, public/.well-known/llms.txt, components/GeoSchema.tsx |
| Impact |
Easy discovery of lead schema, endpoints, and client_id multi-tenancy — amplifies High findings #2–#4. |
| Fix |
Accept for GEO, or split a “demo” surface from authenticated tenant APIs; omit tenant-routing fields from public specs. |
14. Knowledge base documents insecure design as accepted
|
|
| Files |
../decisions/0003-lead-capture-via-mcp-and-openapi.md, ../integrations/mcp-server.md, ../architecture/geo-mcp-gap-analysis.md, ../integrations/lead-capture.md |
| Evidence |
Explicitly: public, unauthenticated MCP. lead-capture.md may still say “No rate limiting” in places that are stale vs the REST limiter. |
| Fix |
Keep ADRs accurate; track MCP rate limit/auth as open production blockers. |
15. Confidential playbook PDF committed to git
|
|
| Files |
knowledge/source/JSGlobtech_GEO_Agency_Playbook.pdf, ../source/README.md |
| Evidence |
Marked confidential business blueprint; not served by Next.js routes (not web-exposed at runtime). |
| Impact |
Pricing/GTM leak if repo access widens or goes public. |
| Fix |
Remove from git; store outside the repo; use history rewrite if already pushed and exposure matters. |
Info
16. Dependency advisories (npm audit --omit=dev, inspected 2026-07-30)
| Package |
Severity |
Notes |
@modelcontextprotocol/sdk via @hono/node-server |
Moderate |
Path traversal in serve-static on Windows; lower relevance if only web-standard transport on Linux is used |
next → postcss / sharp |
High (transitive) |
Upgrade Next when patched releases land |
Do not run npm audit fix --force — it may attempt a breaking Next downgrade. Prefer intentional version bumps when fixes are available.
17. robots.ts allows broad crawl
app/robots.ts — allow: "/" for *. Intentional for GEO; API abuse still depends on auth/rate limits above.
18. JSON-LD via dangerouslySetInnerHTML
components/GeoSchema.tsx — low XSS risk given controlled/static data. Prefer standard patterns if data ever becomes user-influenced.
SSRF assessment
| Question |
Finding |
| User-controlled webhook URL? |
No — only CRM_WEBHOOK_URL / CLIENT_WEBHOOK_* / CLIENT_WEBHOOK_MAP |
| Classic SSRF from request body? |
Not evidenced |
| URL leakage? |
Yes, in server errors/logs — not in sanitized HTTP JSON responses |
Production blockers (priority order)
- Shared secret on Apps Script + stop treating the Web App URL as the sole auth.
- Real API-key → tenant mapping; remove public
client_id routing authority.
- Rate-limit MCP with a distributed store; fix IP trust in
getClientIp.
- Stop logging PII and webhook URLs.
- Max-length validation + Sheets formula escaping.
- Baseline security headers.
| ID |
Severity |
Status |
| #1 Apps Script auth |
Critical |
Open |
| #2 Tenant spoofing |
High |
Open |
#3 Fake X-API-Key |
High |
Open |
| #4 MCP unauth / unrate-limited |
High |
Open |
| #5 Spoofable IP |
High |
Open |
| #6 In-memory limiter |
High |
Open |
| #7 Webhook URL in logs |
Medium |
Open |
| #8 PII in logs |
Medium |
Open |
| #9 Missing max lengths |
Medium |
Open |
| #10 Sheet formula injection |
Medium |
Open |
| #11 Security headers |
Medium |
Open |
| #12 Contact form no delivery |
Medium |
Open |
| #13 Public discovery surface |
Low |
Accepted risk (GEO) unless tenants ship |
| #14 Stale KB claims |
Low |
Open |
| #15 Confidential PDF in git |
Low |
Open |
| #16 Dep advisories |
Info |
Monitor |
| #17 Broad robots |
Info |
Accepted (GEO) |
| #18 JSON-LD innerHTML |
Info |
OK while data is static |
When a finding is fixed in code, flip its status here and note the PR/commit in the same change that lands the fix.