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 -2
View File
@@ -8,7 +8,8 @@ on:
env:
REGISTRY: gitea.dev.marcus-law.co.il
IMAGE: mcp-servers/nadlan-mcp
COOLIFY_UUID: b8uf2yysbgeo1un4r9fbwt35
# COOLIFY_UUID is provided as a Gitea Actions secret to avoid leaking
# the infra mapping in source control.
jobs:
build-and-deploy:
@@ -51,5 +52,5 @@ jobs:
- name: Trigger Coolify redeploy
run: |
curl -sf \
"http://coolify:8080/api/v1/deploy?uuid=${{ env.COOLIFY_UUID }}&force=true" \
"http://coolify:8080/api/v1/deploy?uuid=${{ secrets.COOLIFY_UUID }}&force=true" \
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
+22
View File
@@ -302,3 +302,25 @@ PHASE*.md
# Archived files (historical reference, not tracked)
.archive/
dev-debug.log
# Environment variables
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS specific
# Task files
# tasks.json
# tasks/
# Task Master config (may contain API keys)
.taskmaster/config.json
.taskmaster/state.json
.taskmaster/reports/
+229
View File
@@ -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"
]
}
}
}
+47
View File
@@ -0,0 +1,47 @@
<context>
# Overview
[Provide a high-level overview of your product here. Explain what problem it solves, who it's for, and why it's valuable.]
# Core Features
[List and describe the main features of your product. For each feature, include:
- What it does
- Why it's important
- How it works at a high level]
# User Experience
[Describe the user journey and experience. Include:
- User personas
- Key user flows
- UI/UX considerations]
</context>
<PRD>
# Technical Architecture
[Outline the technical implementation details:
- System components
- Data models
- APIs and integrations
- Infrastructure requirements]
# Development Roadmap
[Break down the development process into phases:
- MVP requirements
- Future enhancements
- Do not think about timelines whatsoever -- all that matters is scope and detailing exactly what needs to be build in each phase so it can later be cut up into tasks]
# Logical Dependency Chain
[Define the logical order of development:
- Which features need to be built first (foundation)
- Getting as quickly as possible to something usable/visible front end that works
- Properly pacing and scoping each feature so it is atomic but can also be built upon and improved as development approaches]
# Risks and Mitigations
[Identify potential risks and how they'll be addressed:
- Technical challenges
- Figuring out the MVP that we can build upon
- Resource constraints]
# Appendix
[Include any additional information:
- Research findings
- Technical specifications]
</PRD>
+511
View File
@@ -0,0 +1,511 @@
<rpg-method>
# Repository Planning Graph (RPG) Method - PRD Template
This template teaches you (AI or human) how to create structured, dependency-aware PRDs using the RPG methodology from Microsoft Research. The key insight: separate WHAT (functional) from HOW (structural), then connect them with explicit dependencies.
## Core Principles
1. **Dual-Semantics**: Think functional (capabilities) AND structural (code organization) separately, then map them
2. **Explicit Dependencies**: Never assume - always state what depends on what
3. **Topological Order**: Build foundation first, then layers on top
4. **Progressive Refinement**: Start broad, refine iteratively
## How to Use This Template
- Follow the instructions in each `<instruction>` block
- Look at `<example>` blocks to see good vs bad patterns
- Fill in the content sections with your project details
- The AI reading this will learn the RPG method by following along
- Task Master will parse the resulting PRD into dependency-aware tasks
## Recommended Tools for Creating PRDs
When using this template to **create** a PRD (not parse it), use **code-context-aware AI assistants** for best results:
**Why?** The AI needs to understand your existing codebase to make good architectural decisions about modules, dependencies, and integration points.
**Recommended tools:**
- **Claude Code** (claude-code CLI) - Best for structured reasoning and large contexts
- **Cursor/Windsurf** - IDE integration with full codebase context
- **Gemini CLI** (gemini-cli) - Massive context window for large codebases
- **Codex/Grok CLI** - Strong code generation with context awareness
**Note:** Once your PRD is created, `task-master parse-prd` works with any configured AI model - it just needs to read the PRD text itself, not your codebase.
</rpg-method>
---
<overview>
<instruction>
Start with the problem, not the solution. Be specific about:
- What pain point exists?
- Who experiences it?
- Why existing solutions don't work?
- What success looks like (measurable outcomes)?
Keep this section focused - don't jump into implementation details yet.
</instruction>
## Problem Statement
[Describe the core problem. Be concrete about user pain points.]
## Target Users
[Define personas, their workflows, and what they're trying to achieve.]
## Success Metrics
[Quantifiable outcomes. Examples: "80% task completion via autopilot", "< 5% manual intervention rate"]
</overview>
---
<functional-decomposition>
<instruction>
Now think about CAPABILITIES (what the system DOES), not code structure yet.
Step 1: Identify high-level capability domains
- Think: "What major things does this system do?"
- Examples: Data Management, Core Processing, Presentation Layer
Step 2: For each capability, enumerate specific features
- Use explore-exploit strategy:
* Exploit: What features are REQUIRED for core value?
* Explore: What features make this domain COMPLETE?
Step 3: For each feature, define:
- Description: What it does in one sentence
- Inputs: What data/context it needs
- Outputs: What it produces/returns
- Behavior: Key logic or transformations
<example type="good">
Capability: Data Validation
Feature: Schema validation
- Description: Validate JSON payloads against defined schemas
- Inputs: JSON object, schema definition
- Outputs: Validation result (pass/fail) + error details
- Behavior: Iterate fields, check types, enforce constraints
Feature: Business rule validation
- Description: Apply domain-specific validation rules
- Inputs: Validated data object, rule set
- Outputs: Boolean + list of violated rules
- Behavior: Execute rules sequentially, short-circuit on failure
</example>
<example type="bad">
Capability: validation.js
(Problem: This is a FILE, not a CAPABILITY. Mixing structure into functional thinking.)
Capability: Validation
Feature: Make sure data is good
(Problem: Too vague. No inputs/outputs. Not actionable.)
</example>
</instruction>
## Capability Tree
### Capability: [Name]
[Brief description of what this capability domain covers]
#### Feature: [Name]
- **Description**: [One sentence]
- **Inputs**: [What it needs]
- **Outputs**: [What it produces]
- **Behavior**: [Key logic]
#### Feature: [Name]
- **Description**:
- **Inputs**:
- **Outputs**:
- **Behavior**:
### Capability: [Name]
...
</functional-decomposition>
---
<structural-decomposition>
<instruction>
NOW think about code organization. Map capabilities to actual file/folder structure.
Rules:
1. Each capability maps to a module (folder or file)
2. Features within a capability map to functions/classes
3. Use clear module boundaries - each module has ONE responsibility
4. Define what each module exports (public interface)
The goal: Create a clear mapping between "what it does" (functional) and "where it lives" (structural).
<example type="good">
Capability: Data Validation
→ Maps to: src/validation/
├── schema-validator.js (Schema validation feature)
├── rule-validator.js (Business rule validation feature)
└── index.js (Public exports)
Exports:
- validateSchema(data, schema)
- validateRules(data, rules)
</example>
<example type="bad">
Capability: Data Validation
→ Maps to: src/utils.js
(Problem: "utils" is not a clear module boundary. Where do I find validation logic?)
Capability: Data Validation
→ Maps to: src/validation/everything.js
(Problem: One giant file. Features should map to separate files for maintainability.)
</example>
</instruction>
## Repository Structure
```
project-root/
├── src/
│ ├── [module-name]/ # Maps to: [Capability Name]
│ │ ├── [file].js # Maps to: [Feature Name]
│ │ └── index.js # Public exports
│ └── [module-name]/
├── tests/
└── docs/
```
## Module Definitions
### Module: [Name]
- **Maps to capability**: [Capability from functional decomposition]
- **Responsibility**: [Single clear purpose]
- **File structure**:
```
module-name/
├── feature1.js
├── feature2.js
└── index.js
```
- **Exports**:
- `functionName()` - [what it does]
- `ClassName` - [what it does]
</structural-decomposition>
---
<dependency-graph>
<instruction>
This is THE CRITICAL SECTION for Task Master parsing.
Define explicit dependencies between modules. This creates the topological order for task execution.
Rules:
1. List modules in dependency order (foundation first)
2. For each module, state what it depends on
3. Foundation modules should have NO dependencies
4. Every non-foundation module should depend on at least one other module
5. Think: "What must EXIST before I can build this module?"
<example type="good">
Foundation Layer (no dependencies):
- error-handling: No dependencies
- config-manager: No dependencies
- base-types: No dependencies
Data Layer:
- schema-validator: Depends on [base-types, error-handling]
- data-ingestion: Depends on [schema-validator, config-manager]
Core Layer:
- algorithm-engine: Depends on [base-types, error-handling]
- pipeline-orchestrator: Depends on [algorithm-engine, data-ingestion]
</example>
<example type="bad">
- validation: Depends on API
- API: Depends on validation
(Problem: Circular dependency. This will cause build/runtime issues.)
- user-auth: Depends on everything
(Problem: Too many dependencies. Should be more focused.)
</example>
</instruction>
## Dependency Chain
### Foundation Layer (Phase 0)
No dependencies - these are built first.
- **[Module Name]**: [What it provides]
- **[Module Name]**: [What it provides]
### [Layer Name] (Phase 1)
- **[Module Name]**: Depends on [[module-from-phase-0], [module-from-phase-0]]
- **[Module Name]**: Depends on [[module-from-phase-0]]
### [Layer Name] (Phase 2)
- **[Module Name]**: Depends on [[module-from-phase-1], [module-from-foundation]]
[Continue building up layers...]
</dependency-graph>
---
<implementation-roadmap>
<instruction>
Turn the dependency graph into concrete development phases.
Each phase should:
1. Have clear entry criteria (what must exist before starting)
2. Contain tasks that can be parallelized (no inter-dependencies within phase)
3. Have clear exit criteria (how do we know phase is complete?)
4. Build toward something USABLE (not just infrastructure)
Phase ordering follows topological sort of dependency graph.
<example type="good">
Phase 0: Foundation
Entry: Clean repository
Tasks:
- Implement error handling utilities
- Create base type definitions
- Setup configuration system
Exit: Other modules can import foundation without errors
Phase 1: Data Layer
Entry: Phase 0 complete
Tasks:
- Implement schema validator (uses: base types, error handling)
- Build data ingestion pipeline (uses: validator, config)
Exit: End-to-end data flow from input to validated output
</example>
<example type="bad">
Phase 1: Build Everything
Tasks:
- API
- Database
- UI
- Tests
(Problem: No clear focus. Too broad. Dependencies not considered.)
</example>
</instruction>
## Development Phases
### Phase 0: [Foundation Name]
**Goal**: [What foundational capability this establishes]
**Entry Criteria**: [What must be true before starting]
**Tasks**:
- [ ] [Task name] (depends on: [none or list])
- Acceptance criteria: [How we know it's done]
- Test strategy: [What tests prove it works]
- [ ] [Task name] (depends on: [none or list])
**Exit Criteria**: [Observable outcome that proves phase complete]
**Delivers**: [What can users/developers do after this phase?]
---
### Phase 1: [Layer Name]
**Goal**:
**Entry Criteria**: Phase 0 complete
**Tasks**:
- [ ] [Task name] (depends on: [[tasks-from-phase-0]])
- [ ] [Task name] (depends on: [[tasks-from-phase-0]])
**Exit Criteria**:
**Delivers**:
---
[Continue with more phases...]
</implementation-roadmap>
---
<test-strategy>
<instruction>
Define how testing will be integrated throughout development (TDD approach).
Specify:
1. Test pyramid ratios (unit vs integration vs e2e)
2. Coverage requirements
3. Critical test scenarios
4. Test generation guidelines for Surgical Test Generator
This section guides the AI when generating tests during the RED phase of TDD.
<example type="good">
Critical Test Scenarios for Data Validation module:
- Happy path: Valid data passes all checks
- Edge cases: Empty strings, null values, boundary numbers
- Error cases: Invalid types, missing required fields
- Integration: Validator works with ingestion pipeline
</example>
</instruction>
## Test Pyramid
```
/\
/E2E\ ← [X]% (End-to-end, slow, comprehensive)
/------\
/Integration\ ← [Y]% (Module interactions)
/------------\
/ Unit Tests \ ← [Z]% (Fast, isolated, deterministic)
/----------------\
```
## Coverage Requirements
- Line coverage: [X]% minimum
- Branch coverage: [X]% minimum
- Function coverage: [X]% minimum
- Statement coverage: [X]% minimum
## Critical Test Scenarios
### [Module/Feature Name]
**Happy path**:
- [Scenario description]
- Expected: [What should happen]
**Edge cases**:
- [Scenario description]
- Expected: [What should happen]
**Error cases**:
- [Scenario description]
- Expected: [How system handles failure]
**Integration points**:
- [What interactions to test]
- Expected: [End-to-end behavior]
## Test Generation Guidelines
[Specific instructions for Surgical Test Generator about what to focus on, what patterns to follow, project-specific test conventions]
</test-strategy>
---
<architecture>
<instruction>
Describe technical architecture, data models, and key design decisions.
Keep this section AFTER functional/structural decomposition - implementation details come after understanding structure.
</instruction>
## System Components
[Major architectural pieces and their responsibilities]
## Data Models
[Core data structures, schemas, database design]
## Technology Stack
[Languages, frameworks, key libraries]
**Decision: [Technology/Pattern]**
- **Rationale**: [Why chosen]
- **Trade-offs**: [What we're giving up]
- **Alternatives considered**: [What else we looked at]
</architecture>
---
<risks>
<instruction>
Identify risks that could derail development and how to mitigate them.
Categories:
- Technical risks (complexity, unknowns)
- Dependency risks (blocking issues)
- Scope risks (creep, underestimation)
</instruction>
## Technical Risks
**Risk**: [Description]
- **Impact**: [High/Medium/Low - effect on project]
- **Likelihood**: [High/Medium/Low]
- **Mitigation**: [How to address]
- **Fallback**: [Plan B if mitigation fails]
## Dependency Risks
[External dependencies, blocking issues]
## Scope Risks
[Scope creep, underestimation, unclear requirements]
</risks>
---
<appendix>
## References
[Papers, documentation, similar systems]
## Glossary
[Domain-specific terms]
## Open Questions
[Things to resolve during development]
</appendix>
---
<task-master-integration>
# How Task Master Uses This PRD
When you run `task-master parse-prd <file>.txt`, the parser:
1. **Extracts capabilities** → Main tasks
- Each `### Capability:` becomes a top-level task
2. **Extracts features** → Subtasks
- Each `#### Feature:` becomes a subtask under its capability
3. **Parses dependencies** → Task dependencies
- `Depends on: [X, Y]` sets task.dependencies = ["X", "Y"]
4. **Orders by phases** → Task priorities
- Phase 0 tasks = highest priority
- Phase N tasks = lower priority, properly sequenced
5. **Uses test strategy** → Test generation context
- Feeds test scenarios to Surgical Test Generator during implementation
**Result**: A dependency-aware task graph that can be executed in topological order.
## Why RPG Structure Matters
Traditional flat PRDs lead to:
- ❌ Unclear task dependencies
- ❌ Arbitrary task ordering
- ❌ Circular dependencies discovered late
- ❌ Poorly scoped tasks
RPG-structured PRDs provide:
- ✅ Explicit dependency chains
- ✅ Topological execution order
- ✅ Clear module boundaries
- ✅ Validated task graph before implementation
## Tips for Best Results
1. **Spend time on dependency graph** - This is the most valuable section for Task Master
2. **Keep features atomic** - Each feature should be independently testable
3. **Progressive refinement** - Start broad, use `task-master expand` to break down complex tasks
4. **Use research mode** - `task-master parse-prd --research` leverages AI for better task generation
</task-master-integration>
+5 -3
View File
@@ -1,5 +1,7 @@
# ─── Stage 1: build the React UI ────────────────────────────────────────
FROM node:22-alpine AS web-build
# Pinned to digest to neutralize tag-hijack and supply-chain replay.
# Renovate/dependabot can update this digest periodically.
FROM node:22-alpine@sha256:8ea2348b068a9544dae7317b4f3aafcdc032df1647bb7d768a05a5cad1a7683f AS web-build
WORKDIR /web
# Cache deps separately from sources.
@@ -10,7 +12,7 @@ COPY web/ .
RUN npm run build
# ─── Stage 2: Python runtime ────────────────────────────────────────────
FROM python:3.13-slim
FROM python:3.13-slim@sha256:a0779d7c12fc20be6ec6b4ddc901a4fd7657b8a6bc9def9d3fde89ed5efe0a3d
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
@@ -43,6 +45,6 @@ USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:${PORT:-8000}/health')" || exit 1
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
CMD ["python", "run_http_server.py"]
+6 -1
View File
@@ -554,7 +554,12 @@ class GovmapClient:
try:
# Step 1: Get coordinates for the address
logger.info(f"Starting search for address: {address}, dealType: {deal_type}")
# Don't log the address itself — it's PII. Length is a safe proxy.
logger.info(
"Starting search for address (length=%d, dealType=%d)",
len(address),
deal_type,
)
autocomplete_result = self.autocomplete_address(address)
if not autocomplete_result.results:
+47 -1
View File
@@ -138,6 +138,25 @@ class Deal(BaseModel):
priority: Optional[int] = Field(
None, description="Priority for sorting (0=same building, 1=street, 2=neighborhood)"
)
# Set after parsing by find_recent_deals_for_address based on which
# polygon the deal was sourced from. Not from upstream — explicit so
# extra='ignore' (M-2) doesn't strip it on serialization.
deal_source: Optional[str] = Field(
None,
description="Provenance bucket: same_building / street / neighborhood",
)
deal_type: Optional[int] = Field(
None,
description="Deal type filter applied (1=first hand, 2=second hand)",
)
deal_type_description: Optional[str] = Field(
None,
description="Human-readable deal-type label",
)
distance_meters: Optional[float] = Field(
None,
description="Distance from search-center coordinates (meters)",
)
# Geometry and internal fields (often not useful for analysis)
shape: Optional[str] = Field(None, description="WKT geometry")
@@ -146,9 +165,36 @@ class Deal(BaseModel):
)
sourceorder: Optional[int] = Field(None, description="Source ordering")
# Cadastral identifiers (used by the UI columns גוש/חלקה/תת-חלקה).
# Modeled explicitly so we no longer rely on `extra="allow"` to leak
# arbitrary upstream keys into the response — see security audit M-2.
gush_num: Optional[int] = Field(None, alias="gushNum", description="Block (גוש) number")
parcel_num: Optional[int] = Field(None, alias="parcelNum", description="Parcel (חלקה) number")
sub_parcel_num: Optional[int] = Field(
None, alias="subParcelNum", description="Sub-parcel (תת-חלקה) number"
)
# Govmap uses two different street/house keys depending on the source
# endpoint. `street_name` (alias `streetName`) is set by some APIs;
# `street_name_heb` (alias `streetNameHeb`) is what the UI table reads.
# `settlement_name_heb` is already declared above.
street_name_heb: Optional[str] = Field(
None, alias="streetNameHeb", description="Street name in Hebrew"
)
house_num: Optional[Any] = Field(
None,
alias="houseNum",
description="House number (Govmap-side; may differ from house_number)",
)
model_config = ConfigDict(
populate_by_name=True, # Allow both alias and field name
extra="allow", # Allow extra fields from API that we don't model
# `ignore` (not `forbid`): Govmap occasionally introduces new
# fields and we don't want validation to break. But we DO want
# to stop silently propagating unknown content into our response
# (which `allow` did). All upstream fields we actually use are
# now modeled explicitly above.
extra="ignore",
)
@computed_field
+339 -23
View File
@@ -11,15 +11,18 @@ Layout:
from __future__ import annotations
import contextlib
import logging
import os
from pathlib import Path
import re
from typing import Any
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from starlette.responses import FileResponse, JSONResponse
from nadlan_mcp.fastmcp_server import (
@@ -33,12 +36,48 @@ from nadlan_mcp.govil.client import ALLOWED_PDF_HOSTS
logger = logging.getLogger(__name__)
# Environment switch — controls visibility of internal-leak surfaces like
# Swagger and openapi.json. Defaults to "production" so an unset env var
# is the safe choice.
ENVIRONMENT = os.getenv("ENVIRONMENT", "production").lower()
IS_PROD = ENVIRONMENT in {"production", "prod"}
# Hard cap for proxied PDF downloads (bytes). Above this we refuse rather
# than buffer/stream. Justice-ministry decisions are rarely >5MB; 50MB is
# a generous ceiling that still bounds memory use.
MAX_PDF_SIZE = 50 * 1024 * 1024
# Generic error string returned to clients in lieu of `str(e)` — see H-1.
GENERIC_UPSTREAM_ERROR = "Service temporarily unavailable, please retry"
# ──────────────────────────────────────────────────────────────────────
# Request / response schemas
# ──────────────────────────────────────────────────────────────────────
_RE_BLOCK = re.compile(r"^\d{1,8}$")
_RE_PLOT = re.compile(r"^\d{1,6}$")
_RE_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
# Address: Hebrew letters, Latin letters, digits, spaces, common punctuation
# (comma, dash, dot, slash, quote, apostrophe). Excludes control chars and
# null bytes that could trip downstream APIs.
_RE_ADDRESS = re.compile(r"^[֐-׿A-Za-z0-9\s,\-./'\"\(\)״׳]+$")
# Person/committee names (Hebrew + Latin + spaces + dashes + dots)
_RE_NAME = re.compile(r"^[֐-׿A-Za-z\s\-.׳״']+$")
def _validate_optional_pattern(value: str | None, pattern: re.Pattern, field: str) -> str | None:
if value is None:
return None
value = value.strip()
if not value:
return None
if not pattern.match(value):
raise ValueError(f"{field}: invalid format")
return value
class DealsSearchRequest(BaseModel):
address: str = Field(..., min_length=2, max_length=200)
years_back: int = Field(2, ge=1, le=10)
@@ -46,6 +85,14 @@ class DealsSearchRequest(BaseModel):
max_deals: int = Field(50, ge=1, le=200)
deal_type: int = Field(2, ge=1, le=2)
@field_validator("address")
@classmethod
def _check_address(cls, v: str) -> str:
v = v.strip()
if not _RE_ADDRESS.match(v):
raise ValueError("address contains disallowed characters")
return v
class AppraisalsSearchRequest(BaseModel):
block: str | None = Field(None, max_length=20)
@@ -65,6 +112,43 @@ class AppraisalsSearchRequest(BaseModel):
include_nearby: bool = Field(False)
nearby_block_radius: int = Field(2, ge=1, le=10)
@field_validator("block")
@classmethod
def _check_block(cls, v):
return _validate_optional_pattern(v, _RE_BLOCK, "block")
@field_validator("plot")
@classmethod
def _check_plot(cls, v):
return _validate_optional_pattern(v, _RE_PLOT, "plot")
@field_validator("decisive_appraiser", "committee")
@classmethod
def _check_name(cls, v):
return _validate_optional_pattern(v, _RE_NAME, "name")
@field_validator(
"decision_date_from",
"decision_date_to",
"publicity_date_from",
"publicity_date_to",
)
@classmethod
def _check_date(cls, v):
return _validate_optional_pattern(v, _RE_DATE, "date")
@field_validator("search_text", "appraisal_header")
@classmethod
def _check_freetext(cls, v):
if v is None:
return None
v = v.strip()
if not v:
return None
if not _RE_ADDRESS.match(v):
raise ValueError("text contains disallowed characters")
return v
# ──────────────────────────────────────────────────────────────────────
# App factory
@@ -94,20 +178,178 @@ def create_app() -> FastAPI:
"raw FastMCP endpoint at /mcp for LLM clients."
),
version="2.1.0",
docs_url="/api/docs",
# Hide API schema in production to avoid handing a roadmap to attackers.
docs_url=None if IS_PROD else "/api/docs",
redoc_url=None,
openapi_url="/api/openapi.json",
openapi_url=None if IS_PROD else "/api/openapi.json",
lifespan=mcp_app.router.lifespan_context,
)
# CORS — only relevant during local dev when Vite serves on :5173.
# CORS — origins overridable via ALLOWED_ORIGINS env (comma-separated).
# Default keeps the local-dev Vite origins so contributors aren't broken;
# production sets ALLOWED_ORIGINS explicitly (or empty to disable cross-origin).
cors_default = "http://localhost:5173,http://127.0.0.1:5173"
cors_origins = [
o.strip() for o in os.getenv("ALLOWED_ORIGINS", cors_default).split(",") if o.strip()
]
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_origins=cors_origins,
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["*"],
allow_headers=["Content-Type"],
allow_credentials=False,
max_age=600,
)
# ── Optional API-key gate (C-2) ─────────────────────────────────
# Defense-in-depth complement to Traefik mTLS (which is the canonical
# solution but not yet enabled). When NADLAN_API_KEY is set, requests
# to non-public paths must present a matching X-API-Key header.
# When unset, the gate is bypassed — preserving the current public
# behavior so this is safe to ship before mTLS lands.
API_KEY = os.getenv("NADLAN_API_KEY", "").strip() or None
AUTH_REQUIRED_PATHS = ("/api/search/", "/api/appraisals/pdf", "/mcp")
PUBLIC_PATHS = ("/api/health", "/health")
@app.middleware("http")
async def api_key_gate(request, call_next):
if API_KEY is None:
return await call_next(request)
path = request.url.path
if path in PUBLIC_PATHS:
return await call_next(request)
if not any(path.startswith(p) for p in AUTH_REQUIRED_PATHS):
return await call_next(request)
provided = request.headers.get("x-api-key", "").strip()
# Constant-time compare to defeat timing oracles.
import hmac
if not provided or not hmac.compare_digest(provided, API_KEY):
return JSONResponse({"detail": "Unauthorized"}, status_code=401)
return await call_next(request)
# ── Per-IP rate limiter (H-5) ───────────────────────────────────
# Token bucket per client IP — protects upstream Govmap/gov.il from
# bulk scraping via /api/* and /mcp, and prevents one attacker from
# exhausting the shared rate-limit budget. Buckets are kept in
# process-local memory; a single uvicorn worker is enough at our
# scale, and the limit is a defense-in-depth complement to Traefik's
# own RateLimit middleware (when added).
import asyncio
from collections import defaultdict
RATE_LIMIT_PATHS = (
("/mcp", 30, 60.0), # 30 req / 60s on /mcp/*
("/api/search/", 60, 60.0), # 60 req / 60s on /api/search/*
("/api/appraisals/pdf", 30, 60.0), # 30 PDF fetches / 60s
)
_rl_buckets: dict[tuple[str, str], list[float]] = defaultdict(list)
_rl_lock = asyncio.Lock()
def _client_ip(request) -> str:
# Coolify/Traefik forward the real client IP via X-Forwarded-For.
# Take the first entry (left-most), which by convention is the
# actual remote client.
xff = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
if xff:
return xff
return request.client.host if request.client else "unknown"
@app.middleware("http")
async def rate_limit(request, call_next):
path = request.url.path
rule = next(
(r for r in RATE_LIMIT_PATHS if path.startswith(r[0])),
None,
)
if rule is None:
return await call_next(request)
prefix, limit, window = rule
ip = _client_ip(request)
key = (prefix, ip)
import time
now = time.monotonic()
async with _rl_lock:
bucket = _rl_buckets[key]
cutoff = now - window
# Drop timestamps that fell out of the window.
bucket[:] = [t for t in bucket if t > cutoff]
if len(bucket) >= limit:
retry_after = max(1, int(window - (now - bucket[0])))
resp = JSONResponse({"detail": "Rate limit exceeded"}, status_code=429)
resp.headers["Retry-After"] = str(retry_after)
return resp
bucket.append(now)
return await call_next(request)
# Reject oversized request bodies before parsing — this app's largest
# legitimate POST is a few hundred bytes. 1MB is generous.
MAX_REQUEST_BODY = 1 * 1024 * 1024
@app.middleware("http")
async def limit_body_size(request, call_next):
cl = request.headers.get("content-length")
if cl is not None:
try:
if int(cl) > MAX_REQUEST_BODY:
return JSONResponse({"detail": "Request body too large"}, status_code=413)
except ValueError:
return JSONResponse({"detail": "Invalid Content-Length"}, status_code=400)
return await call_next(request)
# Set HTTP security headers on every response. Done as a single
# middleware because we want one place to manage the CSP allowlist
# as the UI evolves (e.g. adding Mapbox would require connect-src).
@app.middleware("http")
async def security_headers(request, call_next):
response = await call_next(request)
# MIME-sniffing protection — important for the PDF proxy.
response.headers.setdefault("X-Content-Type-Options", "nosniff")
# Clickjacking — also enforced by CSP frame-ancestors below.
response.headers.setdefault("X-Frame-Options", "DENY")
# Don't leak query-string PII (searched addresses, gush/חלקה) to
# third parties via the Referer header.
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
# No need for the camera/microphone/geo APIs in this product.
response.headers.setdefault(
"Permissions-Policy",
"geolocation=(), microphone=(), camera=(), payment=(), usb=()",
)
# CSP — UI is React+Vite (no eval, no inline scripts at build time).
# Tailwind injects style attributes at runtime, hence 'unsafe-inline'
# for style-src. Heebo is now self-hosted via @fontsource so we no
# longer need to allow Google Fonts.
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"font-src 'self' data:; "
"img-src 'self' data: blob:; "
"connect-src 'self'; "
"frame-ancestors 'none'; "
"form-action 'self'; "
"base-uri 'self'; "
"object-src 'none'",
)
# HSTS — only meaningful behind HTTPS, but our Traefik enforces it
# in both dev and prod, so always set.
response.headers.setdefault(
"Strict-Transport-Security", "max-age=31536000; includeSubDomains"
)
# Drop the server-version banner.
response.headers.pop("server", None)
# Cache hashed asset bundles aggressively (Vite already adds a hash
# to filenames so cache invalidation is a build problem, not a
# runtime one). Everything else: don't cache.
path = request.url.path
if path.startswith("/assets/"):
response.headers.setdefault("Cache-Control", "public, max-age=31536000, immutable")
elif path == "/" or path.endswith(".html"):
response.headers.setdefault("Cache-Control", "no-cache, no-store, must-revalidate")
return response
# ── Health ────────────────────────────────────────────────────────
@app.get("/api/health")
@app.get("/health")
@@ -121,9 +363,9 @@ def create_app() -> FastAPI:
available) the gush/חלקה for the top match."""
try:
response = govmap_client.autocomplete_address(q)
except Exception as e:
except Exception:
logger.exception("autocomplete_address failed")
raise HTTPException(status_code=502, detail=str(e))
raise HTTPException(status_code=502, detail=GENERIC_UPSTREAM_ERROR)
results = []
for r in response.results:
@@ -149,8 +391,10 @@ def create_app() -> FastAPI:
coords = response.results[0].coordinates
gh = _query_parcel_layer(govmap_client, coords.longitude, coords.latitude)
gush_helka = _extract_gush_helka(gh)
except Exception as e:
logger.warning(f"parcel lookup failed for '{q}': {e}")
except Exception:
# Don't log the user-supplied query — it's PII (address)
# and goes to whoever has access to the container logs.
logger.warning("parcel lookup failed (query length=%d)", len(q))
return {"query": q, "results": results, "gush_helka": gush_helka}
@@ -166,9 +410,9 @@ def create_app() -> FastAPI:
req.max_deals,
req.deal_type,
)
except Exception as e:
except Exception:
logger.exception("find_recent_deals_for_address failed")
raise HTTPException(status_code=502, detail=str(e))
raise HTTPException(status_code=502, detail=GENERIC_UPSTREAM_ERROR)
# Search-center coords + resolved address text for the UI (best-effort).
search_coords = None
@@ -243,9 +487,9 @@ def create_app() -> FastAPI:
try:
exact_response = _run(req.block, req.plot)
except Exception as e:
except Exception:
logger.exception("search_decisions_paged (exact) failed")
raise HTTPException(status_code=502, detail=str(e))
raise HTTPException(status_code=502, detail=GENERIC_UPSTREAM_ERROR)
# Dedupe across buckets by a composite key (gov.il doesn't expose a
# single stable id we can rely on).
@@ -333,7 +577,7 @@ def create_app() -> FastAPI:
# ── /api/appraisals/pdf ───────────────────────────────────────────
@app.get("/api/appraisals/pdf")
async def proxy_appraisal_pdf(url: str = Query(..., min_length=20)):
async def proxy_appraisal_pdf(url: str = Query(..., min_length=20, max_length=2048)):
"""
Stream a decisive-appraiser PDF through this server.
@@ -342,12 +586,36 @@ def create_app() -> FastAPI:
"""
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.scheme != "https" or parsed.hostname not in ALLOWED_PDF_HOSTS:
# ── URL validation (C-1: defeat userinfo-bypass SSRF) ───────────
# urlparse(...).hostname returns the value after the optional
# `user:pass@` userinfo, so a URL like
# https://attacker.com@pub-justice.openapi.gov.il/x.pdf
# has hostname == pub-justice.openapi.gov.il *and* would be fetched
# from attacker.com by curl. Reject any URL with userinfo, and any
# URL whose port differs from the default for its scheme.
try:
parsed = urlparse(url)
except ValueError:
raise HTTPException(status_code=400, detail="Malformed URL")
if parsed.scheme != "https":
raise HTTPException(status_code=400, detail="Only https URLs are allowed")
if parsed.hostname not in ALLOWED_PDF_HOSTS:
raise HTTPException(status_code=400, detail="URL host not allowed")
if parsed.username is not None or parsed.password is not None:
raise HTTPException(status_code=400, detail="URL must not contain userinfo")
if parsed.port not in (None, 443):
raise HTTPException(status_code=400, detail="URL port not allowed")
# Final sanity: the netloc the URL library will dial must be exactly
# the hostname (no leftover @ or port). This catches edge cases the
# individual checks above miss.
if parsed.netloc.lower() != parsed.hostname.lower():
raise HTTPException(status_code=400, detail="URL netloc mismatch")
try:
# Reuse the curl_cffi session that already carries x-client-id.
# stream=True so we read in chunks rather than buffering the
# whole body in memory (H-4: defeat OOM via large upstream).
response = decisive_appraiser_client._session.get(
url,
timeout=(
@@ -355,26 +623,74 @@ def create_app() -> FastAPI:
decisive_appraiser_client.config.read_timeout * 4,
),
headers={"Accept": "application/pdf,*/*"},
stream=True,
allow_redirects=False,
)
except Exception as e:
except Exception:
logger.exception("PDF proxy upstream call failed")
raise HTTPException(status_code=502, detail=str(e))
raise HTTPException(status_code=502, detail=GENERIC_UPSTREAM_ERROR)
if response.status_code != 200:
with contextlib.suppress(Exception):
response.close()
raise HTTPException(
status_code=response.status_code,
status_code=502,
detail=f"Upstream returned {response.status_code}",
)
if not response.content.startswith(b"%PDF"):
raise HTTPException(status_code=502, detail="Upstream did not return a PDF")
# Refuse before reading anything if upstream advertises an oversized body.
try:
advertised = int(response.headers.get("Content-Length", "0") or 0)
except ValueError:
advertised = 0
if advertised > MAX_PDF_SIZE:
with contextlib.suppress(Exception):
response.close()
raise HTTPException(status_code=413, detail="Upstream PDF too large")
# Suggest a reasonable filename for the browser.
filename = parsed.path.rstrip("/").split("/")[-1] or "appraisal"
if not filename.lower().endswith(".pdf"):
filename = f"{filename}.pdf"
# Peek the first chunk synchronously so we can verify the PDF
# magic header before the StreamingResponse takes over (we can't
# raise HTTPException from inside the generator).
chunks = response.iter_content(chunk_size=8192)
try:
first_chunk = next(chunks)
except StopIteration:
with contextlib.suppress(Exception):
response.close()
raise HTTPException(status_code=502, detail="Empty upstream response")
except Exception:
logger.exception("PDF proxy: upstream stream failed")
with contextlib.suppress(Exception):
response.close()
raise HTTPException(status_code=502, detail=GENERIC_UPSTREAM_ERROR)
if not first_chunk.startswith(b"%PDF"):
with contextlib.suppress(Exception):
response.close()
raise HTTPException(status_code=502, detail="Upstream did not return a PDF")
def _iter() -> Any:
yield response.content
seen = len(first_chunk)
yield first_chunk
try:
for chunk in chunks:
if not chunk:
continue
seen += len(chunk)
if seen > MAX_PDF_SIZE:
logger.warning(
f"PDF proxy: upstream exceeded {MAX_PDF_SIZE} bytes; truncating"
)
return
yield chunk
finally:
with contextlib.suppress(Exception):
response.close()
return StreamingResponse(
_iter(),
+10 -6
View File
@@ -22,14 +22,18 @@ classifiers = [
"Programming Language :: Python :: 3.13",
]
# Runtime deps are pinned to a known-good upper bound (~=) so a Docker
# rebuild reproduces the same versions, but minor/patch upgrades come
# in voluntarily via PR. ~= is "compatible-release" in PEP 440 — locks
# to the same major+minor (e.g. ~=2.13 means >=2.13,<3.0).
dependencies = [
"requests>=2.31.0",
"python-dotenv>=1.0.0",
"pydantic>=2.0.0",
"requests~=2.33",
"python-dotenv~=1.2",
"pydantic~=2.13",
"fastmcp>=2.13.0,<3.0.0",
"curl_cffi>=0.5.0",
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
"curl_cffi~=0.15",
"fastapi~=0.136",
"uvicorn[standard]~=0.46",
]
[project.optional-dependencies]
+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({