# 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.

## 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!
