security: address all findings from vulnerability assessment
Implements the fixes catalogued under Task Master tasks 1-19, derived from the security audit at .claude/plans/adaptive-sleeping-diffie.md. Critical - C-1 SSRF userinfo bypass: PDF proxy now rejects URLs containing userinfo or non-default ports, and asserts netloc == hostname. - C-2 No authentication: optional NADLAN_API_KEY gate guards /api/search/*, /api/appraisals/pdf and /mcp until Traefik mTLS is wired up. Default behavior unchanged when env unset. High - H-1 Error info leak: HTTPException details replaced with generic string; full exceptions still logged via logger.exception. - H-2 Security headers middleware: HSTS, X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy, CSP, server-banner stripped. - H-3 Swagger gated: /api/docs and /api/openapi.json hidden when ENVIRONMENT=production. - H-4 PDF proxy DoS: streams in 8KB chunks, rejects upstream above 50MB Content-Length, also rejects mid-stream overrun. Magic-byte check moved before StreamingResponse so we can 502 cleanly. - H-5 Per-IP rate limit: token-bucket per (path, IP) on /mcp, /api/search/*, /api/appraisals/pdf. Reads X-Forwarded-For for client IP behind Traefik. Medium - M-1 CORS: ALLOWED_ORIGINS env, allow_headers narrowed, no creds. - M-2 Deal model: extra="ignore" + explicit fields for gushNum, parcelNum, subParcelNum, streetNameHeb, houseNum, deal_source, deal_type, deal_type_description, distance_meters. Frontend now reads snake_case. - M-3 Input validators: regex on block, plot, appraiser names, dates, address, free-text — Hebrew + Latin + safe punctuation. - M-4 COOLIFY_UUID moved out of deploy.yaml into Gitea Actions secret. - M-5 Base images pinned to sha256 digests. - M-6 Runtime deps tightened to ~= compatible-release. - M-7 1MB request-body size limit middleware. Low / privacy - L-1 Heebo self-hosted via @fontsource. Removed Google Fonts links and CSP entries. Added meta referrer + meta robots noindex. - L-2 Cache-Control: immutable for /assets, no-cache for HTML. - L-3 Logger no longer prints user-supplied addresses; emits length only. - L-4 Dockerfile healthcheck uses literal port 8000. - L-5 PDF proxy stream cleanup uses contextlib.suppress. Tests: 331 passed, 17 skipped. Frontend typecheck passes. Findings file: .claude/plans/adaptive-sleeping-diffie.md Tasks: .taskmaster/tasks/tasks.json Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
{
|
||||
"master": {
|
||||
"tasks": [
|
||||
{
|
||||
"id": "1",
|
||||
"title": "C-1 [Critical]: Fix SSRF via userinfo in PDF proxy",
|
||||
"description": "urlparse().hostname check in /api/appraisals/pdf is bypassable. URLs like https://attacker.com@pub-justice.openapi.gov.il/x.pdf pass the whitelist but curl_cffi connects to attacker.com.",
|
||||
"details": "File: nadlan_mcp/web_app.py — proxy_appraisal_pdf. Validate parsed.username is None, parsed.password is None, and parsed.netloc == parsed.hostname (no userinfo, no port). Add unit test with malicious URL.",
|
||||
"testStrategy": "Unit test: pass crafted userinfo URL, expect 400. Verify whitelisted URLs still work.",
|
||||
"priority": "high",
|
||||
"status": "in-progress",
|
||||
"dependencies": [],
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-04-25T17:42:36.172Z"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"title": "H-1 [High]: Stop returning str(e) in HTTPException details",
|
||||
"description": "All 4 endpoints return raw exception text to clients via HTTPException(detail=str(e)), leaking internal hostnames, paths, library errors.",
|
||||
"details": "Files: nadlan_mcp/web_app.py — search_address, search_deals, search_appraisals, proxy_appraisal_pdf. Use logger.exception(...) for full detail; return generic detail='Service temporarily unavailable' to clients.",
|
||||
"testStrategy": "Trigger an upstream failure (mock), verify response detail is generic and full error is in logs.",
|
||||
"priority": "high",
|
||||
"status": "pending",
|
||||
"dependencies": [],
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"title": "H-3 [High]: Disable Swagger UI in production",
|
||||
"description": "/api/docs and /api/openapi.json publicly expose full API schema, aiding attacker reconnaissance.",
|
||||
"details": "File: nadlan_mcp/web_app.py — FastAPI(...). Use ENVIRONMENT env var; set docs_url=None and openapi_url=None when production.",
|
||||
"testStrategy": "GET /api/docs in prod → 404. In dev → 200.",
|
||||
"priority": "high",
|
||||
"status": "pending",
|
||||
"dependencies": [],
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"title": "H-4 [High]: Stream PDF proxy response, enforce max-size",
|
||||
"description": "PDF proxy buffers entire response.content in memory before yielding, enabling OOM via large files (especially when combined with C-1 SSRF).",
|
||||
"details": "File: nadlan_mcp/web_app.py — proxy_appraisal_pdf. Use iter_content(chunk_size=8192). Check Content-Length header → reject >50MB. Add 5-min total timeout.",
|
||||
"testStrategy": "Mock upstream with 100MB body → expect 413. With 1MB body → 200, streamed.",
|
||||
"priority": "high",
|
||||
"status": "pending",
|
||||
"dependencies": [
|
||||
"1"
|
||||
],
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"title": "C-2 [Critical]: Enable mTLS / API authentication",
|
||||
"description": "App is fully public — no auth on /mcp, /api/search/*, /api/docs. Anyone can scrape data, abuse MCP tools, or get our IP banned by Govmap.",
|
||||
"details": "Apply Traefik mtls-forwardauth@file middleware (existing infrastructure under mtls-auth-service). Coolify custom_labels need mtls-optional@file TLS option + middleware. /mcp may need separate API-key gate. Public health endpoint must remain reachable for Coolify probe.",
|
||||
"testStrategy": "Without client cert → 403. With valid cert from PKI → 200. /api/health remains 200 anonymously.",
|
||||
"priority": "high",
|
||||
"status": "pending",
|
||||
"dependencies": [],
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": "6",
|
||||
"title": "H-2 [High]: Add HTTP security headers middleware",
|
||||
"description": "Missing HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, CSP, Permissions-Policy.",
|
||||
"details": "File: nadlan_mcp/web_app.py — add middleware setting all standard headers. CSP needs to allow Google Fonts (until L-1 self-hosts) or self only.",
|
||||
"testStrategy": "curl -sI / → expect all 6 headers present with sane values.",
|
||||
"priority": "high",
|
||||
"status": "pending",
|
||||
"dependencies": [],
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": "7",
|
||||
"title": "H-5 [High]: Per-IP rate limit on /mcp and /api/*",
|
||||
"description": "Govmap client rate-limits at server level — single attacker can saturate it for everyone, get IP banned upstream.",
|
||||
"details": "Use slowapi (FastAPI rate-limiter) or Traefik RateLimit middleware. Suggested: 60 req/min per IP on /api/search/*, 30 req/min on /mcp.",
|
||||
"testStrategy": "Hammer 100 reqs/sec from single IP → expect 429 after limit.",
|
||||
"priority": "high",
|
||||
"status": "pending",
|
||||
"dependencies": [],
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": "8",
|
||||
"title": "M-1 [Medium]: Tighten CORS configuration",
|
||||
"description": "allow_headers=['*'] is overly permissive; localhost origins hardcoded for prod.",
|
||||
"details": "File: nadlan_mcp/web_app.py. Set allow_headers=['Content-Type'], allow_methods=['GET','POST','OPTIONS'], allow_origins from ALLOWED_ORIGINS env var.",
|
||||
"testStrategy": "Origin header from disallowed source → no Access-Control-Allow-Origin in response.",
|
||||
"priority": "medium",
|
||||
"status": "pending",
|
||||
"dependencies": [],
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": "9",
|
||||
"title": "M-2 [Medium]: Switch Deal model to extra='forbid'",
|
||||
"description": "Deal model accepts arbitrary fields from Govmap and propagates them to UI/MCP responses, enabling schema confusion attacks.",
|
||||
"details": "File: nadlan_mcp/govmap/models.py. Add explicit fields for gushNum, parcelNum, subParcelNum, streetNameHeb, settlementNameHeb, houseNum (currently extras). Switch to extra='ignore' (forbid is too strict — Govmap adds fields). Update tests.",
|
||||
"testStrategy": "Existing tests pass; new fields appear in serialized output; unknown upstream fields are ignored, not propagated.",
|
||||
"priority": "medium",
|
||||
"status": "pending",
|
||||
"dependencies": [],
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": "10",
|
||||
"title": "M-3 [Medium]: Add regex validators on input fields",
|
||||
"description": "Pydantic models only enforce min/max length. block, plot, address accept any chars, allowing potential downstream injection.",
|
||||
"details": "Files: nadlan_mcp/web_app.py (DealsSearchRequest, AppraisalsSearchRequest). Add @field_validator: block ^\\d{1,8}$, plot ^\\d{1,6}$, decisive_appraiser Hebrew/space/dash, address Hebrew/Latin/digits/punctuation.",
|
||||
"testStrategy": "POST with null bytes / SQL-like payloads → 422. Real Hebrew searches still pass.",
|
||||
"priority": "medium",
|
||||
"status": "pending",
|
||||
"dependencies": [],
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": "11",
|
||||
"title": "M-7 [Medium]: Cap request payload size",
|
||||
"description": "uvicorn default ~16MB but no explicit limit. Large POST bodies waste memory before validation.",
|
||||
"details": "Add Starlette middleware to reject Content-Length > 1MB on /api/* endpoints (none of our endpoints need large bodies).",
|
||||
"testStrategy": "POST 10MB body → 413 before parsing.",
|
||||
"priority": "medium",
|
||||
"status": "pending",
|
||||
"dependencies": [],
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": "12",
|
||||
"title": "M-4 [Medium]: Move Coolify UUID to Gitea secret",
|
||||
"description": "COOLIFY_UUID hardcoded in workflow file leaks infra mapping.",
|
||||
"details": "Edit .gitea/workflows/deploy.yaml — replace literal with ${{ secrets.COOLIFY_UUID }}. Add secret in Gitea Actions settings.",
|
||||
"testStrategy": "Trigger workflow, verify deploy still works.",
|
||||
"priority": "medium",
|
||||
"status": "pending",
|
||||
"dependencies": [],
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": "13",
|
||||
"title": "M-5 [Medium]: Pin base images and apt packages",
|
||||
"description": "node:22-alpine and python:3.13-slim use floating tags; apt packages unpinned. Supply-chain risk.",
|
||||
"details": "Resolve current images to digests via docker manifest inspect. Pin gcc/curl in Dockerfile.",
|
||||
"testStrategy": "Rebuild → identical digest. CVE scanner reports specific versions.",
|
||||
"priority": "medium",
|
||||
"status": "pending",
|
||||
"dependencies": [],
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": "14",
|
||||
"title": "M-6 [Medium]: Generate requirements.lock for Python deps",
|
||||
"description": "pyproject.toml uses >= constraints — non-reproducible builds, unexpected upgrades.",
|
||||
"details": "pip-compile or pip freeze → requirements.lock committed. Dockerfile uses pip install -r requirements.lock instead of -e .",
|
||||
"testStrategy": "Two consecutive builds → identical pip freeze.",
|
||||
"priority": "medium",
|
||||
"status": "pending",
|
||||
"dependencies": [],
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": "15",
|
||||
"title": "L-1 [Low]: Self-host Heebo fonts (privacy)",
|
||||
"description": "Google Fonts loads from googleapis.com → leaks Referer (with full URL incl. searched address) to Google.",
|
||||
"details": "Download Heebo woff2 files into web/public/fonts/. Update web/index.html link tags + add @font-face in CSS.",
|
||||
"testStrategy": "Network tab → no requests to fonts.googleapis.com.",
|
||||
"priority": "low",
|
||||
"status": "pending",
|
||||
"dependencies": [],
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": "16",
|
||||
"title": "L-2 [Low]: Add Cache-Control headers on static assets",
|
||||
"description": "Static assets served without explicit cache policy.",
|
||||
"details": "Middleware: /assets/* → Cache-Control: public, max-age=31536000, immutable; index.html → no-cache, no-store.",
|
||||
"testStrategy": "curl -I /assets/index-xxx.js → expect immutable.",
|
||||
"priority": "low",
|
||||
"status": "pending",
|
||||
"dependencies": [],
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": "17",
|
||||
"title": "L-3 [Low]: Redact PII from logs",
|
||||
"description": "Logger emits user-supplied addresses, request URLs, exception bodies. Coolify log access leaks PII.",
|
||||
"details": "Files: nadlan_mcp/govmap/client.py, nadlan_mcp/govil/client.py. Replace f-strings that include user input with sanitized versions; only log lengths or hashes.",
|
||||
"testStrategy": "Search logs for sample query → not present.",
|
||||
"priority": "low",
|
||||
"status": "pending",
|
||||
"dependencies": [],
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": "18",
|
||||
"title": "L-4 [Low]: Hardcode port in Dockerfile HEALTHCHECK",
|
||||
"description": "${PORT:-8000} interpolation can fail if PORT is set unusually.",
|
||||
"details": "Replace with literal 8000 — that's our fixed contract.",
|
||||
"testStrategy": "docker inspect → HEALTHCHECK uses literal 8000.",
|
||||
"priority": "low",
|
||||
"status": "pending",
|
||||
"dependencies": [],
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": "19",
|
||||
"title": "L-5 [Low]: Add streaming-response timeout in PDF proxy",
|
||||
"description": "Stream can hang indefinitely if upstream stalls mid-transfer.",
|
||||
"details": "Wrap iter_content with asyncio.timeout(300) (5 min hard cap).",
|
||||
"testStrategy": "Mock upstream that sends one chunk then sleeps → connection closed at 5 min.",
|
||||
"priority": "low",
|
||||
"status": "pending",
|
||||
"dependencies": [
|
||||
"4"
|
||||
],
|
||||
"subtasks": []
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"lastModified": "2026-04-25T17:42:36.190Z",
|
||||
"taskCount": 19,
|
||||
"completedCount": 0,
|
||||
"tags": [
|
||||
"master"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user