# Security Policy

## Reporting Security Vulnerabilities

We take security seriously in Local Deep Research. If you discover a security vulnerability, please follow these steps:

### 🔒 Private Disclosure

**Please DO NOT open a public issue.** Instead, report vulnerabilities privately through one of these methods:

1. **[GitHub Security Advisories](https://github.com/LearningCircuit/local-deep-research/security/advisories/new)** (Preferred):
   - Click the link above or go to Security tab → Report a vulnerability
   - This creates a private discussion with maintainers

2. **Email**:
   - Send details to the maintainers listed in CODEOWNERS
   - Use "SECURITY:" prefix in subject line

### What to Include

- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Any suggested fixes (optional)

### Our Commitment

- We'll acknowledge receipt within 48 hours
- We'll provide an assessment within 1 week
- We'll work on a fix prioritizing based on severity
- We'll credit you in the fix (unless you prefer anonymity)

## Vulnerability Disclosure Timeline

We follow a coordinated disclosure process with best-effort target timelines:

| Severity | Target Fix Time | Public Disclosure |
| -------- | --------------- | ----------------- |
| Critical | 30 days         | After fix released |
| High     | 45 days         | After fix released |
| Medium   | 60 days         | After fix released |
| Low      | 90 days         | After fix released |

**Note**: This is a community-maintained project. Actual fix times may vary depending on complexity and maintainer availability. We do our best to address security issues promptly.

- **Coordination**: We work with reporters to coordinate disclosure timing
- **Credit**: Reporters are credited in release notes and security advisories (unless anonymity requested)
- **CVE Assignment**: For significant vulnerabilities, we will request CVE assignment through GitHub Security Advisories

## Security Considerations

This project processes user queries and search results. Key areas:

- **No sensitive data in commits** - We use strict whitelisting
- **API key handling** - Always use environment variables
- **Search data** - Queries are processed locally when possible
- **Dependencies** - Regularly updated via automated scanning

### Database Encryption

Local Deep Research uses **SQLCipher** (AES-256-CBC) for database encryption. Each user's database is encrypted with their login password as the key, derived via PBKDF2-HMAC-SHA512 with 256,000 iterations and a per-user random salt. There is no separate password hash — authentication works by attempting to decrypt the database. API keys stored in the database are encrypted at rest.

### In-Memory Credentials

Like all applications that use secrets at runtime — including [password managers](https://www.ise.io/casestudies/password-manager-hacking/), browsers, and API clients — credentials are held in plain text in process memory during active sessions. This is an [industry-wide reality](https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html) acknowledged by [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html), [Microsoft](https://learn.microsoft.com/en-us/dotnet/fundamentals/runtime-libraries/system-security-securestring) (who deprecated `SecureString` for this reason), and the [pyca/cryptography](https://cryptography.io/en/stable/limitations/) library.

**Why in-process encryption does not help:** If an attacker can read process memory, they can also read any decryption key stored in the same process. The password exists in Flask session storage, database connection managers, and thread-local storage throughout the application's lifetime — protecting only one copy (e.g., SQLCipher's internal buffers) does not meaningfully reduce exposure.

**What we do to mitigate:**
- Session-scoped credential lifetimes with automatic expiration
- Core dump exclusion via container security settings

Ideas for further improvements are always welcome via [GitHub Issues](https://github.com/LearningCircuit/local-deep-research/issues).

### Memory Security (`cipher_memory_security`)

SQLCipher's `cipher_memory_security` pragma controls whether SQLCipher zeroes its internal buffers after use and calls `mlock()` to prevent memory pages from being swapped to disk.

**Default: OFF.** Since the same password is unprotected elsewhere in process memory (see above), locking only SQLCipher's internal buffers does not meaningfully reduce exposure.

To enable memory security (e.g., for compliance requirements):

```bash
# Environment variable
LDR_DB_CONFIG_CIPHER_MEMORY_SECURITY=ON
```

In Docker, `mlock()` requires the `IPC_LOCK` capability:

```yaml
# docker-compose.yml
services:
  local-deep-research:
    cap_add:
      - IPC_LOCK
    environment:
      - LDR_DB_CONFIG_CIPHER_MEMORY_SECURITY=ON
```

Or with `docker run`:

```bash
docker run --cap-add IPC_LOCK -e LDR_DB_CONFIG_CIPHER_MEMORY_SECURITY=ON ...
```

`IPC_LOCK` is a narrow Linux capability that only permits memory locking — it does not grant any other privileges.

### Notification Webhook SSRF

**Outbound notifications via Apprise are disabled by default.** To enable them, the operator must set `LDR_NOTIFICATIONS_ALLOW_OUTBOUND=true` in the server environment. This is intentional: notifications carry a known residual SSRF risk that cannot be fully closed in code, and the env-only gate makes turning them on an explicit operator decision rather than something any logged-in user can flip via the settings API.

#### The residual risk

LDR validates user-configured notification service URLs (`NotificationURLValidator`) before handing them to Apprise. Hostnames are resolved once at validation time and the resulting IPs are checked against private/internal ranges. There is a known **DNS rebinding TOCTOU window** between this check and the actual outbound request:

- **The window.** Apprise (and its underlying `requests`/`urllib3` stack) resolves the hostname *again* when it sends the notification. A DNS-rebinding attacker controlling a domain can serve a public IP to LDR's validator and a private IP to Apprise's send-time resolver — bypassing the private-IP check and reaching internal services on the LDR server (e.g., `127.0.0.1:<internal-port>`) or the local network. This is exploitable by any logged-in user, not just by the deployment operator.
- **Why it isn't closed in code.** Apprise exposes no Session/adapter/DNS hook. Closing the window would require monkey-patching `requests` inside Apprise's plugin namespace — fragile across Apprise versions, HTTPS-only, and doesn't handle redirects correctly. The blast radius outweighs the benefit.

#### How to enable notifications

```bash
LDR_NOTIFICATIONS_ALLOW_OUTBOUND=true
```

By setting this, the operator acknowledges the residual risk above. To minimise it:

- **Prefer plugin schemes over raw `http(s)://`.** Apprise plugin schemes (`discord://`, `slack://`, `ntfy://`, `ntfys://`, `gotify://`, `telegram://`, `mattermost://`, `rocketchat://`, `teams://`, `matrix://`, `mailto://`, etc.) hardcode their endpoints internally and have no user-controllable hostname — no SSRF surface. Use them whenever the target service supports them.
- **Restrict egress** if private-network exposure is a concern: deploy LDR behind an egress-restricted network so that even a successful rebinding cannot reach internal services.

The same DNS-rebinding caveat applies to `safe_requests` / `ssrf_validator.validate_url`, used for general HTTP fetches (RAG sources, web scraping). Egress restriction is the primary defense for that path as well.

### Parser-Differential URL Bypass (GHSA-g23j-2vwm-5c25)

A reporter ([@Fushuling](https://github.com/Fushuling), [@RacerZ-fighting](https://github.com/RacerZ-fighting)) demonstrated that Python's `urllib.parse.urlparse` and the `requests`/`urllib3` parser disagreed on URLs like `http://127.0.0.1\@1.1.1.1` — `urlparse` extracted `1.1.1.1` (passing the SSRF check) while `requests` connected to `127.0.0.1` (the actual destination). The fix has two layers:

- **Layer 1 — input hygiene:** `RFC_FORBIDDEN_URL_CHARS_RE` in `ssrf_validator.py` rejects URLs containing backslash, ASCII control bytes, or whitespace. RFC 3986 forbids these characters in URLs, so legitimate fetches are unaffected.
- **Layer 2 — authoritative parser:** Hostname extraction now uses `urllib3.util.parse_url`, the same parser `requests` uses internally. Validator and HTTP client cannot disagree on destination by construction. This is the load-bearing defence on the `SafeSession.send` path, where `requests` has already canonicalised `\` to `%5C` during `.prepare()`.

Both `ssrf_validator.validate_url` and `NotificationURLValidator.validate_service_url` (HTTP/HTTPS branch) carry the fix. Future edits to the SSRF path should preserve `RFC_FORBIDDEN_URL_CHARS_RE` and the `urllib3.util.parse_url` host extraction — reverting either reintroduces the bypass.

### Cloud Metadata Endpoint Block List

`ssrf_validator.ALWAYS_BLOCKED_METADATA_IPS` is a frozenset of cloud-provider metadata IPs that are blocked under every flag combination, including `allow_localhost=True` and `allow_private_ips=True`. These IPs expose IAM / instance-role credentials and are never legitimate destinations for outbound HTTP. The current set is:

| IP | Provider |
| --- | --- |
| `169.254.169.254` | AWS IMDSv1/v2, Azure, OCI, DigitalOcean (shared) |
| `169.254.170.2` | AWS ECS task metadata v3 |
| `169.254.170.23` | AWS ECS task metadata v4 |
| `169.254.0.23` | Tencent Cloud |
| `100.100.100.200` | AlibabaCloud |

The block also catches IPv6-wrapped forms of these metadata IPs. When an IPv6 destination falls in a NAT64 prefix (`64:ff9b::/96` RFC 6052 well-known or `64:ff9b:1::/48` RFC 8215 local-use), the validator extracts the embedded IPv4 from the low 32 bits and matches it against this set — so `[64:ff9b::a9fe:a9fe]` cannot reach `169.254.169.254` even on a host with NAT64 routes configured. The check fires before any opt-in carve-out, so the operator switch described below cannot license IMDS exposure.

Both `ssrf_validator.is_ip_blocked` and `NotificationURLValidator.validate_service_url` enforce this absolutely, including under `allow_private_ips=True`. The latter flag is an operator opt-in for self-hosted webhooks on internal networks (RFC1918, CGNAT, loopback, link-local, IPv6 ULA); it does NOT extend to metadata IPs or NAT64-wrapped metadata. Both validators delegate to the same `is_ip_blocked` helper to keep the absolute-block invariant in lockstep.

Future contributors must not remove entries from this set. Adding a new cloud provider's metadata IP is encouraged when a new public-cloud target appears.

### IPv6 Transition Prefix Block List

`PRIVATE_IP_RANGES` blocks four IPv6 prefixes that can wrap private-IPv4 destinations on hosts with kernel transition routes configured:

| Prefix | Purpose | RFC |
| --- | --- | --- |
| `2002::/16` | 6to4 | RFC 3056 (deprecated by RFC 7526) |
| `64:ff9b::/96` | NAT64 well-known prefix | RFC 6052 |
| `64:ff9b:1::/48` | NAT64 local-use prefix | RFC 8215 |
| `2001::/32` | Teredo | RFC 4380 |
| `100::/64` | IPv6 discard prefix | RFC 6666 |
| `::/96` | IPv4-Compatible IPv6 (deprecated) | RFC 4291 §2.5.5.1 |

Default Linux has no `sit0` / NAT64 routes so this is defensive-only on the typical deployment, but blocking these prefixes closes the IPv6-wrapped SSRF bypass class on hosts where transition tunnels are enabled.

Operators on IPv6-only deployments using DNS64+NAT64 (AWS / GKE / Azure IPv6-only nodes) reach IPv4 services through `64:ff9b::/96`. They can opt back into NAT64 reachability via the env-only setting `security.allow_nat64` (`LDR_SECURITY_ALLOW_NAT64=true`). The opt-in is scoped strictly to the two NAT64 prefixes — 6to4, Teredo, and discard remain unconditionally blocked because they have no live legitimate use, and the IMDS embedded-IPv4 check above still applies so cloud metadata stays unreachable through any NAT64 wrap.

URL rejection log lines route through `ssrf_validator.redact_url_for_log` to drop userinfo (RFC 3986 §3.2.1 allows credentials in the URL), path, and query — operators see `scheme://host:port` only. Operators with grep/regex tooling on the rejection log lines will see authority-only strings instead of full URLs.

### LLM Provider URL Validation

Operator-configured LLM endpoints (`llm.ollama.url`, `llm.lmstudio.url`, `llm.llamacpp.url`, `llm.openai_endpoint.url`) are validated against the same SSRF rules as outbound HTTP via `ssrf_validator.assert_base_url_safe`. Validation runs immediately after `normalize_url` and before the LangChain SDK constructor (`ChatOpenAI` / `ChatOllama`), so a misconfigured or hostile URL fails fast rather than silently routing every inference call at internal services.

The guard uses `allow_localhost=True, allow_private_ips=True` because the legitimate destinations for these providers are localhost (Ollama, LM Studio, llama.cpp) and RFC1918 (Docker / private network deployments). The `ALWAYS_BLOCKED_METADATA_IPS` set still fires under those flags, so cloud-credential endpoints stay blocked regardless of the operator's settings.

Caveats:
- Settings-write access already implies arbitrary-URL exfil and RFC1918 reachability — the guard prevents only the cloud-credential-endpoint pivot, not exfil to public attacker-controlled hosts (the configured `Authorization: Bearer …` header would still go to the attacker if they swapped the URL).
- DNS rebinding TOCTOU applies: the guard validates once at provider construction, but the SDK re-resolves the hostname on every inference call. Same accepted-risk rationale as elsewhere in the project — egress restriction at the firewall is the operator-side mitigation.
- Providers with no operator-configurable URL (`Anthropic`, `OpenAI`, `OpenRouter`, `xAI`, `IONOS`) skip validation by gating on `cls.url_setting`. Their `default_base_url` is hardcoded to a public API endpoint; no SSRF surface to attack.

### Egress Policy Module

LDR includes an optional egress-policy subsystem (`security/egress/` — see its [`README.md`](src/local_deep_research/security/egress/README.md) for the full design, the scope table, and the map of every enforcement point) that lets operators constrain where research traffic, LLM calls, and embeddings may go. The module is an **in-process correctness guardrail, NOT a hard security boundary**. It defends against honest misconfiguration, prompt-injection-induced URL fetches, accidental egress, and the LangGraph silent-expansion class of bug. It does **NOT** defend against:

- compromised dependencies that bypass the PEPs
- code execution inside the LDR process
- an adversary who can modify the policy module itself

Operators needing a hard boundary **must** layer OS-level controls: network namespaces, host-level firewall rules (egress filtering), restricted container runtimes. The egress policy is a guard rail for the application's own code paths; it cannot constrain a malicious actor who can write code that runs inside the same Python interpreter.

#### Threat vectors covered

| Vector | Defence |
|---|---|
| LangGraph silent search-engine expansion | Factory PEP at `create_search_engine` rejects engines not permitted under the active scope; the tool-list filter hides forbidden tools from the LLM. |
| Cloud LLM under "local-only" claim | `get_llm()` PEP refuses cloud providers / non-private LLM URLs when `llm.require_local_endpoint=true`. STRICT+meta-picker misconfig fails closed. |
| Cloud embeddings under "local-only" claim | Pre-flight policy check in `LibraryRAGService.__init__` covers all 5 direct construction sites + the factory. SentenceTransformer download from HuggingFace refused on cache miss under `embeddings.require_local=true`. |
| Prompt-injection-induced URL fetches | The agent `fetch_content` tool now calls `evaluate_url()` and raises `PolicyDeniedError` on denial. Subagents propagate the policy context. |
| PRIVATE_ONLY chain break | `ssrf_validator.policy_aware_validate_url()` lets the user's local lab deployments (Ollama on 127.0.0.1, SearXNG on 192.168.x) actually be reached under `PRIVATE_ONLY` without forcing the operator to set `SSRF_ALLOW_PRIVATE_IPS=1` globally. |
| Cache-hit policy bypass | `SearchCache._get_query_hash` incorporates the active scope; a `scope=BOTH` cache entry isn't returned to a later `scope=PRIVATE_ONLY` query. |
| NAT64 wrap of cloud metadata | `_classify_host` consults `is_nat64_wrapped_metadata_ip` before `is_private_ip`, so `64:ff9b::169.254.169.254` classifies as public (not as the link-local it superficially appears to be). |
| DNS race / process-global socket timeout | DNS resolution runs inside a single-shot `ThreadPoolExecutor` with `Future.result(timeout=2.0)` — no `socket.setdefaulttimeout()` mutation of process-global state. The worker is abandoned via `shutdown(wait=False)` so a hung lookup can't block past the timeout. |
| Cloud-metadata fetch under any scope | `evaluate_url()` rejects cloud-metadata IPs (`169.254.169.254`, ECS, IPv4-mapped forms) regardless of scope. They classify as link-local, so STRICT/PRIVATE_ONLY would otherwise *allow* them — notably via the audit-hook net which calls `evaluate_url` on raw `socket.connect` targets (bypassing the SSRF validator the fetch PEPs run first). |
| Private collection data → cloud model | Each collection carries a public/private flag (default **private**). A private collection is excluded under PUBLIC_ONLY / Adaptive-public scope and forces local LLM/embeddings inference under PRIVATE_ONLY / Adaptive-private. The **Adaptive** scope (default) derives the effective scope from the primary engine, so a private-collection primary keeps the whole run local automatically. |

#### Caveats

- **DNS rebinding TOCTOU**: `_classify_host` resolves once at evaluation time; the actual HTTP request resolves again at connect time. Closing this race would require pinning the resolved IP into the outbound connection, which is HTTPS-only and doesn't follow redirects cleanly. See the "Notification Webhook SSRF" subsection for the accepted-risk rationale (the same caveat applies here).
- **Settings tampering**: an adversary with write access to a user's per-user settings DB can flip `policy.egress_scope` to `both` and disable all enforcement. Per-user SQLCipher databases mean one user can't trivially tamper with another's policy, but an attacker who can read/write a user's DB can change anything about that user's runtime. Policy-key changes emit `policy_audit=True` log lines so admins can audit changes after the fact.
- **Audit log routing**: `policy_audit=True` log lines are filtered from the WebSocket sink (`frontend_progress_sink`), so they never reach a CORS-permissive browser observer. They are written to the loguru file/console sinks and persisted to the encrypted research-log DB if a research is active.
- **LLM/embeddings endpoint locality is best-effort**: the "stays local" guarantee is *strong* for named cloud providers (OpenAI, Anthropic, Google, OpenRouter, etc.) and localhost-default providers (Ollama/LM Studio/llama.cpp) — those are classified by name and reliably blocked/allowed. It is *weaker* for configurable-URL providers (`openai_endpoint`): the endpoint is classified by resolving its host, so an OpenAI-compatible endpoint pointed at a private-looking IP (split-horizon DNS, a tunnel, a proxy) is **trusted as local**. A user who wants to process private-collection data with a cloud LLM is expected to mark the collection **public** (the explicit opt-in); the policy prevents *accidental/silent* egress, not a determined user who deliberately points "local" inference at a cloud endpoint. This is consistent with the guardrail-not-boundary model above.

#### Configuration

See `docs/CONFIGURATION.md` for the user-facing keys
(`policy.egress_scope` — default **`adaptive`**, which follows your primary
engine; `llm.require_local_endpoint`; `embeddings.require_local`;
`llm.allowed_local_hostnames`), the per-collection public/private flag, the
per-research overrides, and the audit-log behaviour.

## Supported Versions

Security fixes are only provided for the latest release. Please upgrade to receive patches.

## Security Scanning & CI/CD

We maintain comprehensive automated security scanning across the entire development lifecycle:

### Static Application Security Testing (SAST)

| Tool | Purpose | Frequency |
|------|---------|-----------|
| **CodeQL** | Semantic code analysis for vulnerabilities | Every PR & push |
| **Semgrep** | Pattern-based security scanning | Every PR & push |
| **Bandit** | Python-specific security linting | Every PR & push |
| **DevSkim** | Security-focused linter | Every PR & push |

### Dependency & Supply Chain Security

| Tool | Purpose | Frequency |
|------|---------|-----------|
| **OSV-Scanner** | Open Source Vulnerability database | Every PR & push |
| **npm audit** | JavaScript dependency vulnerabilities | Every PR & push |
| **RetireJS** | Known vulnerable JS libraries | Every PR & push |
| **SBOM Generation** | Software Bill of Materials (Syft) | Weekly & releases |
| **License Scanning** | License compliance checking | Every PR |

### Container Security

| Tool | Purpose | Frequency |
|------|---------|-----------|
| **Trivy** | Container vulnerability scanning | Every PR & push |
| **Hadolint** | Dockerfile best practices | Every PR & push |
| **Dockle** | Container image security linting | Weekly |
| **Image Pinning** | Verify all images use SHA digests | Every PR |

### Infrastructure & Configuration

| Tool | Purpose | Frequency |
|------|---------|-----------|
| **Checkov** | Infrastructure-as-Code security | Every PR & push |
| **Zizmor** | GitHub Actions security | Every PR & push |
| **OSSF Scorecard** | Supply chain security metrics | Periodic |

### Dynamic Application Security Testing (DAST)

| Tool | Purpose | Frequency |
|------|---------|-----------|
| **OWASP ZAP** | Web application security scanning | Every PR & push |
| **Security Headers** | HTTP security header validation | Every PR & push |

### Secrets Detection

| Tool | Purpose | Frequency |
|------|---------|-----------|
| **Gitleaks** | Secret detection in commits | Every PR & push |
| **File Whitelist** | Prevent sensitive files in commits | Every PR & push |

> **Note:** detect-secrets (Yelp) was removed in Feb 2026 because its
> line-number-based `.secrets.baseline` file caused constant merge conflicts
> across branches. Gitleaks provides equivalent pattern-based detection with
> path-based allowlists that are stable across line changes.
> CI also runs Semgrep (`p/secrets`) and Bearer (`secrets`) for additional coverage.
> Do not re-add detect-secrets.

### Release Security

| Feature | Description |
|---------|-------------|
| **Cosign Signing** | All Docker images are cryptographically signed |
| **SLSA Provenance** | Build attestations for supply chain verification |
| **SBOM Attachments** | SBOMs attached to container images and releases |
| **Keyless Signing** | Uses GitHub OIDC for Sigstore keyless signing |

### Security Best Practices

All workflows follow security best practices:

- **Pinned Actions**: All GitHub Actions pinned to SHA hashes
- **Minimal Permissions**: Least-privilege permission model
- **Runner Hardening**: step-security/harden-runner on all workflows
- **No Credential Persistence**: `persist-credentials: false` on checkouts
- **Egress Auditing**: Network egress monitoring enabled

### OpenSSF Scorecard

We maintain a high [OpenSSF Scorecard](https://securityscorecards.dev/viewer/?uri=github.com/LearningCircuit/local-deep-research) rating, measuring:

- Branch protection
- Dependency updates
- Security policy
- Signed releases
- CI/CD security

Thank you for helping keep Local Deep Research secure!
