fieldwitness/frontends/web/blueprints/dropbox.py
Aaron D. Lee 2629aabcc5
Some checks failed
CI / typecheck (push) Waiting to run
CI / lint (push) Has been cancelled
Fix 12 security findings from adversarial audit
CRITICAL:
- #1+#2: Consistency proof verification no longer a stub — implements
  actual hash chain reconstruction from proof hashes, rejects proofs
  that don't reconstruct to the expected root. GossipNode._verify_consistency
  now calls verify_consistency_proof() instead of just checking sizes.
- #3: Remove passphrase.lower() from KDF — was silently discarding
  case entropy from mixed-case passphrases. Passphrases are now
  case-sensitive as users would expect.
- #4: Federation gossip now applies record_filter (trust store check)
  on every received record before appending to the log. Untrusted
  attestor fingerprints are rejected with a warning.
- #5: Killswitch disables all logging BEFORE activation to prevent
  audit log from recording killswitch activity that could survive an
  interrupted purge. Audit log destruction moved to position 4 (right
  after keys + flask secret, before other data).

HIGH:
- #6: CSRF exemption narrowed from entire dropbox blueprint to only
  the upload view function. Admin routes retain CSRF protection.
- #7: /health endpoint returns only {"status":"ok"} to anonymous
  callers. Full operational report requires authentication.
- #8: Metadata stripping now reconstructs image from pixel data only
  (Image.new + putdata), stripping XMP, IPTC, and ICC profiles — not
  just EXIF.
- #9: Same as #6 (CSRF scope fix).

MEDIUM:
- #11: Receipt HMAC key changed from public upload token to server-side
  secret key, making valid receipts unforgeable by the source or anyone
  who captured the upload URL.
- #12: Docker CMD no longer defaults to --no-https. HTTPS with
  self-signed cert is the default; --no-https requires explicit opt-in.
- #14: shred return code now checked — non-zero exit falls through to
  the zero-overwrite fallback instead of silently succeeding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:31:03 -04:00

361 lines
13 KiB
Python

"""
Source drop box blueprint — anonymous, token-gated file submission.
Provides a SecureDrop-like intake that lives inside SooSeF:
- Admin creates a time-limited upload token
- Source opens the token URL in a browser (no account needed)
- Files are uploaded, EXIF-stripped, and auto-attested on receipt
- Source receives a one-time receipt code to confirm delivery
- Token self-destructs after use or timeout
"""
from __future__ import annotations
import hashlib
import json
import os
import secrets
from datetime import UTC, datetime, timedelta
from pathlib import Path
from auth import admin_required, login_required
from flask import Blueprint, Response, flash, redirect, render_template, request, url_for
from soosef.audit import log_action
from soosef.paths import AUTH_DIR, TEMP_DIR
bp = Blueprint("dropbox", __name__, url_prefix="/dropbox")
_TOKEN_DIR = TEMP_DIR / "dropbox"
_DB_PATH = AUTH_DIR / "dropbox.db"
def _ensure_token_dir():
_TOKEN_DIR.mkdir(parents=True, exist_ok=True)
_TOKEN_DIR.chmod(0o700)
def _get_db():
"""Get SQLite connection for drop box tokens."""
import sqlite3
_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(_DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("""CREATE TABLE IF NOT EXISTS tokens (
token TEXT PRIMARY KEY,
label TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
max_files INTEGER NOT NULL,
used INTEGER NOT NULL DEFAULT 0
)""")
conn.execute("""CREATE TABLE IF NOT EXISTS receipts (
receipt_code TEXT PRIMARY KEY,
token TEXT NOT NULL,
filename TEXT,
sha256 TEXT,
received_at TEXT,
FOREIGN KEY (token) REFERENCES tokens(token)
)""")
conn.commit()
return conn
def _get_token(token: str) -> dict | None:
"""Load a token from SQLite. Returns dict or None if expired/missing."""
conn = _get_db()
row = conn.execute("SELECT * FROM tokens WHERE token = ?", (token,)).fetchone()
if not row:
conn.close()
return None
if datetime.fromisoformat(row["expires_at"]) < datetime.now(UTC):
conn.execute("DELETE FROM tokens WHERE token = ?", (token,))
conn.commit()
conn.close()
return None
data = dict(row)
# Load receipts
receipts = conn.execute(
"SELECT receipt_code FROM receipts WHERE token = ?", (token,)
).fetchall()
data["receipts"] = [r["receipt_code"] for r in receipts]
conn.close()
return data
def _get_all_tokens() -> dict[str, dict]:
"""Load all non-expired tokens."""
conn = _get_db()
now = datetime.now(UTC).isoformat()
# Clean expired
conn.execute("DELETE FROM tokens WHERE expires_at < ?", (now,))
conn.commit()
rows = conn.execute("SELECT * FROM tokens").fetchall()
result = {}
for row in rows:
data = dict(row)
receipts = conn.execute(
"SELECT receipt_code FROM receipts WHERE token = ?", (row["token"],)
).fetchall()
data["receipts"] = [r["receipt_code"] for r in receipts]
result[row["token"]] = data
conn.close()
return result
@bp.route("/admin", methods=["GET", "POST"])
@admin_required
def admin():
"""Admin panel for creating and managing drop box tokens."""
if request.method == "POST":
action = request.form.get("action")
if action == "create":
label = request.form.get("label", "").strip() or "Unnamed source"
hours = int(request.form.get("hours", 24))
max_files = int(request.form.get("max_files", 10))
token = secrets.token_urlsafe(32)
conn = _get_db()
conn.execute(
"INSERT INTO tokens (token, label, created_at, expires_at, max_files, used) VALUES (?, ?, ?, ?, ?, 0)",
(token, label, datetime.now(UTC).isoformat(), (datetime.now(UTC) + timedelta(hours=hours)).isoformat(), max_files),
)
conn.commit()
conn.close()
log_action(
actor=request.environ.get("REMOTE_USER", "admin"),
action="dropbox.token_created",
target=token[:8],
outcome="success",
source="web",
)
upload_url = url_for("dropbox.upload", token=token, _external=True)
flash(f"Drop box created. Share this URL with your source: {upload_url}", "success")
elif action == "revoke":
tok = request.form.get("token", "")
conn = _get_db()
conn.execute("DELETE FROM receipts WHERE token = ?", (tok,))
conn.execute("DELETE FROM tokens WHERE token = ?", (tok,))
conn.commit()
conn.close()
flash("Token revoked.", "success")
return render_template("dropbox/admin.html", tokens=_get_all_tokens())
def _validate_token(token: str) -> dict | None:
"""Check if a token is valid. Returns token data or None."""
data = _get_token(token)
if data is None:
return None
if data["used"] >= data["max_files"]:
return None
return data
@bp.route("/upload/<token>", methods=["GET", "POST"])
def upload(token):
"""Source-facing upload page. No authentication required."""
token_data = _validate_token(token)
if token_data is None:
return Response(
"This upload link has expired or is invalid.",
status=404,
content_type="text/plain",
)
if request.method == "POST":
files = request.files.getlist("files")
if not files:
return Response("No files provided.", status=400, content_type="text/plain")
_ensure_token_dir()
receipts = []
for f in files:
if token_data["used"] >= token_data["max_files"]:
break
raw_data = f.read()
if not raw_data:
continue
# Extract-then-strip pipeline:
# 1. Extract EXIF into attestation metadata (evidentiary fields)
# 2. Attest the ORIGINAL bytes (hash matches what source submitted)
# 3. Strip metadata from the stored copy (protect source device info)
from soosef.metadata import extract_strip_pipeline
extraction, stripped_data = extract_strip_pipeline(raw_data)
# SHA-256 of what the source actually submitted
sha256 = extraction.original_sha256
# Save the stripped copy for display/storage (no device fingerprint on disk)
dest = _TOKEN_DIR / f"{sha256[:16]}_{f.filename}"
dest.write_bytes(stripped_data)
# Auto-attest the ORIGINAL bytes so the attestation hash matches
# what the source submitted. Evidentiary EXIF (GPS, timestamp)
# is preserved in the attestation metadata; dangerous fields
# (device serial) are excluded.
try:
from soosef.verisoo.attestation import create_attestation
from blueprints.attest import _get_private_key, _get_storage
attest_metadata = {
"source": "dropbox",
"label": token_data["label"],
"stripped_sha256": extraction.stripped_sha256,
}
# Include evidentiary EXIF in attestation (GPS, timestamp)
for key, value in extraction.evidentiary.items():
if hasattr(value, "isoformat"):
attest_metadata[key] = value.isoformat()
elif hasattr(value, "__dataclass_fields__"):
from dataclasses import asdict
attest_metadata[key] = asdict(value)
elif isinstance(value, dict):
attest_metadata[key] = value
else:
attest_metadata[key] = str(value)
private_key = _get_private_key()
if private_key:
attestation = create_attestation(
raw_data, private_key, metadata=attest_metadata
)
storage = _get_storage()
storage.append_record(attestation.record)
except Exception:
pass # Attestation is best-effort; don't fail the upload
# Receipt code derived from file hash via HMAC with a server-side
# secret. The source cannot pre-compute this (the token alone is
# insufficient), making valid receipts unforgeable.
import hmac
from soosef.paths import SECRET_KEY_FILE
server_secret = SECRET_KEY_FILE.read_bytes() if SECRET_KEY_FILE.exists() else token.encode()
receipt_code = hmac.new(
server_secret, sha256.encode(), hashlib.sha256
).hexdigest()[:16]
receipts.append({
"filename": f.filename,
"sha256": sha256,
"receipt_code": receipt_code,
"received_at": datetime.now(UTC).isoformat(),
})
# Persist receipt and increment used count in SQLite
conn = _get_db()
conn.execute(
"INSERT OR IGNORE INTO receipts (receipt_code, token, filename, sha256, received_at) VALUES (?, ?, ?, ?, ?)",
(receipt_code, token, f.filename, sha256, datetime.now(UTC).isoformat()),
)
conn.execute("UPDATE tokens SET used = used + 1 WHERE token = ?", (token,))
conn.commit()
conn.close()
token_data["used"] += 1
remaining = token_data["max_files"] - token_data["used"]
# Return receipt codes as plain text (minimal fingerprint)
receipt_text = "FILES RECEIVED\n" + "=" * 40 + "\n\n"
for r in receipts:
receipt_text += f"File: {r['filename']}\n"
receipt_text += f"Receipt: {r['receipt_code']}\n"
receipt_text += f"SHA-256: {r['sha256']}\n\n"
receipt_text += f"Remaining uploads on this link: {remaining}\n"
receipt_text += "\nSave your receipt codes. They confirm your submission was received.\n"
return Response(receipt_text, content_type="text/plain")
# GET — show upload form with client-side SHA-256 hashing
# Minimal page, no SooSeF branding (source safety)
remaining = token_data["max_files"] - token_data["used"]
return f"""<!DOCTYPE html>
<html><head><title>Secure Upload</title>
<style>
body{{font-family:sans-serif;max-width:600px;margin:40px auto;padding:20px;color:#333}}
input[type=file]{{margin:10px 0}}
button{{padding:10px 20px;font-size:16px}}
#hashes{{background:#f5f5f5;padding:10px;border-radius:4px;font-family:monospace;
font-size:12px;margin:10px 0;display:none;white-space:pre-wrap}}
.hash-label{{color:#666;font-size:11px}}
</style></head>
<body>
<h2>Secure File Upload</h2>
<p>Select files to upload. You may upload up to {remaining} file(s).</p>
<p>Your files will be fingerprinted in your browser before upload. Save the
fingerprints — they prove exactly what you submitted.</p>
<form method="POST" enctype="multipart/form-data" id="uploadForm">
<input type="file" name="files" id="fileInput" multiple
accept="image/*,.pdf,.doc,.docx,.txt"><br>
<div id="hashes"></div>
<button type="submit" id="submitBtn" disabled>Computing fingerprints...</button>
</form>
<p style="color:#666;font-size:12px">This link will expire automatically. Do not bookmark it.</p>
<script>
// Client-side SHA-256 via SubtleCrypto — runs in browser, no server round-trip
async function hashFile(file) {{
const buffer = await file.arrayBuffer();
const hash = await crypto.subtle.digest('SHA-256', buffer);
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2,'0')).join('');
}}
document.getElementById('fileInput').addEventListener('change', async function() {{
const files = this.files;
const hashDiv = document.getElementById('hashes');
const btn = document.getElementById('submitBtn');
if (!files.length) {{ hashDiv.style.display='none'; btn.disabled=true; return; }}
btn.disabled = true;
btn.textContent = 'Computing fingerprints...';
hashDiv.style.display = 'block';
hashDiv.innerHTML = '';
for (const file of files) {{
const hash = await hashFile(file);
hashDiv.innerHTML += '<span class="hash-label">' + file.name + ':</span>\\n' + hash + '\\n\\n';
}}
hashDiv.innerHTML += '<span class="hash-label">Save these fingerprints before uploading.</span>';
btn.disabled = false;
btn.textContent = 'Upload';
}});
</script>
</body></html>"""
@bp.route("/verify-receipt", methods=["POST"])
def verify_receipt():
"""Let a source verify their submission was received by receipt code."""
code = request.form.get("code", "").strip()
if not code:
return Response("No receipt code provided.", status=400, content_type="text/plain")
conn = _get_db()
row = conn.execute(
"SELECT filename, sha256, received_at FROM receipts WHERE receipt_code = ?", (code,)
).fetchone()
conn.close()
if row:
return Response(
f"Receipt {code} is VALID.\n"
f"File: {row['filename']}\n"
f"SHA-256: {row['sha256']}\n"
f"Received: {row['received_at']}\n",
content_type="text/plain",
)
return Response(
f"Receipt {code} was not found. It may have expired.",
status=404,
content_type="text/plain",
)