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:
2026-04-25 17:59:10 +00:00
parent ed8892fb2b
commit 0340a670ed
16 changed files with 1258 additions and 61 deletions
+3 -7
View File
@@ -3,14 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>נדל"ן ושמאי מכריע — Marcus-Law</title>
<title>נדל"ן ושמאי מכריע</title>
<meta name="description" content="חיפוש עסקאות נדל&quot;ן והחלטות שמאי מכריע באזור ספציפי" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Heebo:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
<meta name="referrer" content="strict-origin-when-cross-origin" />
<meta name="robots" content="noindex, nofollow" />
</head>
<body dir="rtl">
<div id="root"></div>
+10
View File
@@ -8,6 +8,7 @@
"name": "nadlan-mcp-web",
"version": "0.1.0",
"dependencies": {
"@fontsource/heebo": "^5.2.8",
"@radix-ui/react-direction": "^1.1.1",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.0",
@@ -771,6 +772,15 @@
"node": ">=18"
}
},
"node_modules/@fontsource/heebo": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/heebo/-/heebo-5.2.8.tgz",
"integrity": "sha512-K5aMLH2Yy/CmmjuM9lpToyIEwSdPKwW6KzZOGCFnj7XFrgatIpnesu6FDgmQl3bkyP/YCT62rzLRqRM9s/OzLw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+1
View File
@@ -11,6 +11,7 @@
"lint": "eslint . --ext ts,tsx --max-warnings=0"
},
"dependencies": {
"@fontsource/heebo": "^5.2.8",
"@radix-ui/react-direction": "^1.1.1",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.0",
+6 -6
View File
@@ -36,12 +36,12 @@ export interface Deal {
floor?: number;
deal_date?: string;
property_type_description?: string;
settlementNameHeb?: string;
streetNameHeb?: string;
houseNum?: string | number;
gushNum?: number | null;
parcelNum?: number | null;
subParcelNum?: number | null;
settlement_name_heb?: string;
street_name_heb?: string;
house_num?: string | number;
gush_num?: number | null;
parcel_num?: number | null;
sub_parcel_num?: number | null;
price_per_sqm?: number;
deal_source?: string | null;
[key: string]: unknown;
+12 -12
View File
@@ -37,11 +37,11 @@ function streetByClosestParcel(
const blockNum = block ? parseInt(block, 10) : null;
let best: { dist: number; street: string } | null = null;
for (const d of deals) {
if (!d.streetNameHeb || d.parcelNum == null) continue;
if (blockNum !== null && d.gushNum !== blockNum) continue;
const dist = Math.abs((d.parcelNum as number) - target);
if (!d.street_name_heb || d.parcel_num == null) continue;
if (blockNum !== null && d.gush_num !== blockNum) continue;
const dist = Math.abs((d.parcel_num as number) - target);
if (!best || dist < best.dist) {
best = { dist, street: d.streetNameHeb };
best = { dist, street: d.street_name_heb };
}
}
return best?.street ?? null;
@@ -52,13 +52,13 @@ function proximityLabel(deal: Deal, inferredStreet: string | null): string {
if (deal.deal_source === "same_building") return "אותו בניין";
if (deal.deal_source === "neighborhood") return "שכונה";
// "street" source — check if actually same street
if (inferredStreet && deal.streetNameHeb === inferredStreet) return "אותו רחוב";
if (inferredStreet && deal.street_name_heb === inferredStreet) return "אותו רחוב";
return "סביבה קרובה";
}
function proximityClass(deal: Deal, inferredStreet: string | null): string {
if (deal.deal_source === "same_building") return "bg-emerald-100 text-emerald-800";
if (deal.deal_source === "street" && inferredStreet && deal.streetNameHeb === inferredStreet)
if (deal.deal_source === "street" && inferredStreet && deal.street_name_heb === inferredStreet)
return "bg-blue-100 text-blue-800";
if (deal.deal_source === "street") return "bg-sky-50 text-sky-700";
return "bg-slate-100 text-slate-700";
@@ -100,9 +100,9 @@ export function DealsTable({ deals, resolvedAddress, searchedBlock, searchedPlot
</thead>
<tbody>
{deals.map((d) => {
const street = d.streetNameHeb || "";
const houseNum = d.houseNum ?? "";
const settlement = d.settlementNameHeb || "";
const street = d.street_name_heb || "";
const houseNum = d.house_num ?? "";
const settlement = d.settlement_name_heb || "";
const address = [street, houseNum, settlement].filter(Boolean).join(" ");
return (
<tr key={d.id} className="border-t hover:bg-accent/40">
@@ -118,9 +118,9 @@ export function DealsTable({ deals, resolvedAddress, searchedBlock, searchedPlot
<td className="p-3 text-center tabular-nums">
{formatCurrencyILS(d.price_per_sqm)}
</td>
<td className="p-3 text-center tabular-nums">{d.gushNum ?? "—"}</td>
<td className="p-3 text-center tabular-nums">{d.parcelNum ?? "—"}</td>
<td className="p-3 text-center tabular-nums">{d.subParcelNum ?? "—"}</td>
<td className="p-3 text-center tabular-nums">{d.gush_num ?? "—"}</td>
<td className="p-3 text-center tabular-nums">{d.parcel_num ?? "—"}</td>
<td className="p-3 text-center tabular-nums">{d.sub_parcel_num ?? "—"}</td>
<td className="p-3">
{d.deal_source ? (
<span
+7
View File
@@ -3,6 +3,13 @@ import ReactDOM from "react-dom/client";
import { DirectionProvider } from "@radix-ui/react-direction";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
// Self-host the Heebo font (privacy: avoids leaking the visited URL to
// Google Fonts via the Referer header — see security audit L-1).
import "@fontsource/heebo/300.css";
import "@fontsource/heebo/400.css";
import "@fontsource/heebo/500.css";
import "@fontsource/heebo/600.css";
import "@fontsource/heebo/700.css";
import "./index.css";
const queryClient = new QueryClient({