exifcleaner-web/metascrub-security-audit.md
Randa 1b0896f264
All checks were successful
CI / Lint, Typecheck & Unit Tests (push) Successful in 52s
CI / Smoke build (VITE_ENABLE_FFMPEG_FALLBACK=false) (push) Successful in 1m10s
CI / E2E (Standalone single-file) (push) Successful in 1m55s
CI / E2E (Web) (push) Successful in 4m1s
docs(security): mark CSP + deps remediation items resolved (PR #196, #197)
Updates the 2026-05-22 audit to reflect the three shipped fixes:
- #2 yarn audit CI gate (PR #196)
- #3 devDependency vulnerabilities 42 → 0 (PR #196)
- #4 style-src 'unsafe-inline' removed from all three CSP layers (PR #197)

New score: 72/82 (was 66/82). Sole remaining HIGH: .env keystore password.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 00:09:07 +04:00

14 KiB
Raw Permalink Blame History

MetaScrub Security Audit — 2026-05-22

Performed by Claude Code (claude-sonnet-4-6) using the auditing-repo-security skill.

Status update — 2026-05-23. Three of eight remediation items shipped: #2 (yarn audit CI gate), #3 (devDependency vulns 42 → 0), and #4 (CSP style-src 'unsafe-inline' removed). New score: 72 / 82 (was 66). Sole remaining HIGH: .env keystore password in plaintext. See "Remediation Plan" and "Resolved Findings" sections below.


Stack Identification

Type: Privacy-focused metadata-stripping single-page application — desktop offline standalone HTML and Android APK (Capacitor wrapper) as primary distributions; self-hosted Docker PWA as a secondary path.

Languages / Frameworks: TypeScript 5.7 (strict), React 19, Vite 7, Capacitor 7, Gradle for Android. WASM via @ffmpeg/core (GPL) and @uswriting/exiftool (zeroperl WebAssembly Perl). No backend; all processing runs in the browser.

Threat model — who is the attacker?

Attacker Surface Realistic?
Malicious file on disk User drops a ZIP-bomb, oversized file, or crafted binary Yes — primary threat
Attacker distributes modified standalone HTML Trojanised offline file replaces the legitimate one Yes — no integrity check at distribution
CI/CD supply-chain Compromised devDependency or GitHub Action injects malicious code Medium
Local user with machine access Reads .env (APK signing key + password) Yes — single-user risk
Network attacker There is no server; CSP blocks all external fetches No (by design)

Trust boundaries: file bytes from the filesystem (the only user-provided input); localStorage (settings, deserialized at startup); inline DOM base64 (WASM bytes in standalone build); CI secrets (keystore password).


Audit Scope

Audited: All files in the repo excluding node_modules/, dist/, .git/, .docker-cache/, .claude/worktrees/, .worktrees/. Examined: all TypeScript source under src/, CI workflows, Dockerfiles, nginx.conf, public/_headers, android/ manifests and Gradle configs, .env, .env.sample, .gitignore, test fixtures.

Tooling run: yarn audit (v1.22.22) — output incorporated below. gitleaks and trufflehog not found on PATH.

LSP available: TypeScript LSP present; used grep/read as primary tooling given the cross-cutting nature of this audit.


Insufficient Data

  • Yarn lockfile CVE check: yarn audit only covers advisories indexed by npm; advisories published after training cutoff may be missed. A real-time SCA scan (Snyk, OSV-Scanner) should supplement.
  • APK binary: the committed android/app/src/main/assets/public/ was inspected for structure but the built APK was not decompiled with apktool to verify the final AndroidManifest permissions list.
  • @6over3/zeroperl-ts WASM capability set: the audit statement "zeroperl.wasm declares zero sock_* imports" comes from the in-repo docs/poc commentary, not from an independent wasm-objdump run in this session.

Executive Summary

MetaScrub has a genuinely strong privacy and security posture for a client-side-only tool: a strict multi-layer CSP (meta tag + nginx + Cloudflare Pages headers), zero network calls in production, no analytics or telemetry, encrypted-ZIP detection, and ZIP-bomb budgeting. As of 2026-05-23, the CSP has been tightened to drop style-src 'unsafe-inline' (PR #197) and all 42 devDependency vulnerabilities have been resolved with a yarn audit --level high CI gate added (PR #196). The most concrete remaining finding is a real APK-signing keystore password held in the local .env file in plaintext — while gitignored and not in VCS, it is the private key to the Android release signing trust chain and sits unencrypted on the developer's disk. The Capacitor config.xml <access origin="*"> and a missing HSTS header in the Docker nginx config are minor hardening gaps.


Score: 72 / 82 (was 66 / 82 at audit time; +6 after 2026-05-23 fixes)

(Domain 1 — AuthN/AuthZ excluded from denominator: local offline tool with no user accounts)

Domain Max Score Δ Key reason
1. AuthN/AuthZ 18 N/A No auth surface — local offline tool
2. Injection & Input Handling 16 14 +1 No server-side injection; ZIP-bomb mitigated; style-src 'unsafe-inline' removed
3. Secrets & Credential Management 14 10 Real keystore password in plaintext .env; no committed secrets
4. Cryptography 10 9 No custom crypto; APK signing uses v1/v2/v3 correctly
5. Data Exposure & Privacy 10 10 Backup disabled, lastModified: 0, eviction on FileEntry removal
6. Security Config & Hardening 10 9 +1 Strong CSP (style-src 'unsafe-inline' removed); missing HSTS; access origin="*"
7. Dependencies & Supply Chain 8 8 +4 2 critical vulns 0 findings; no Dependabot/SCA in CI yarn audit --level high gate added
8. Error Handling & Logging 6 6 Result<T,E> throughout; no sensitive data in console output
9. Code Quality & Hygiene 5 4 No eval; one unusual Blob dynamic import (not exploitable)
10. Infrastructure & Deployment 3 2 Nginx runs as root; no HSTS; CI uses pinned action versions

Critical Issues

(None — no directly exploitable critical-severity issues found in production code or committed secrets)


High-Priority Issues

1. Real APK signing keystore password stored in plaintext .env

  • Severity: HIGH
  • Location: .env:25-26METASCRUB_KEYSTORE_PASSWORD / METASCRUB_KEY_PASSWORD
  • Description: The local .env file (gitignored, not in VCS) contains what appears to be a real passphrase for the APK release-signing keystore — not the change-me placeholder from .env.sample. If a developer's machine is compromised (malware, stolen laptop, screen recording, shell history leak), both the keystore file and its password are exposed from a single device. An attacker who obtains both can sign arbitrary APKs as MetaScrub, enabling trojanised updates that pass Android's signature check.
  • Impact: Full APK signing trust-chain compromise; trojanised releases installable by existing users on Android.
  • Fix: Use a password manager (pass, Bitwarden CLI, 1Password CLI) to retrieve the passphrase at release-build time rather than storing it in a plaintext file. The scripts/build-apk-local.sh already reads from the environment — sourcing from a credential helper instead of .env keeps the password out of any file on disk. Alternatively, encrypt .env with git-crypt or sops and document the decryption ceremony in docs/android-signing.md.

Medium Issues

(All three medium-severity items at audit time have been resolved. See "Resolved Findings" below.)


Resolved Findings (shipped 2026-05-23)

devDependency critical/high vulnerabilities — RESOLVED in PR #196 (commit e35f8aa)

  • Original: 42 vulnerabilities (2 critical minimist prototype pollution via madge, 21 high ansi-regex ReDoS via @capacitor/cli, 19 moderate).
  • Fix shipped: @capacitor/cli upgraded to ^7.6.5, vite upgraded to ^7.3.3, and yarn resolutions forcing patched transitive versions for minimist, ansi-regex, rollup, picomatch, postcss, and brace-expansion.
  • Verified: yarn audit now reports 0 findings.

No Dependabot / SCA wired into CI — RESOLVED in PR #196 (commit e35f8aa)

  • Fix shipped: yarn audit --level high added as a failing step in the CI test job (.github/workflows/ci.yml). New high-or-critical advisories will fail PR checks automatically.

style-src 'unsafe-inline' in all production CSP policies — RESOLVED in PR #197

  • Original: All three CSP enforcement layers included style-src 'self' 'unsafe-inline'.
  • Fix shipped:
    • ErrorExpansion.tsx: two static inline styles moved to BEM classes (.file-table__error-text--copyable, .file-table__copy-hint).
    • SegmentedControl.tsx: dynamic transform migrated to a CSS custom property (--ec-segment-offset) set via useLayoutEffect + ref.style.setProperty — a CSSOM call, not an inline style attribute, so no unsafe-inline needed.
    • vite.config.web.ts: production styleSrc is now 'self' (dev retains 'unsafe-inline' for Vite HMR).
    • nginx.conf + public/_headers: both server-level CSP headers updated to style-src 'self'.
  • Verified: production build's CSP meta tag emits style-src 'self' with no unsafe-inline.

Low Issues

  • nginx.conf — No Strict-Transport-Security header. The Docker self-host config serves on HTTP:80 with no HTTPS redirect; users who deploy behind a TLS terminator get no HSTS. Add add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; with a comment that it should only be set when TLS is active.
  • android/app/src/main/res/xml/config.xml:3<access origin="*" /> (default Capacitor policy). Allows WebView navigation to any URL. No external URLs appear in the app code, so the practical risk is negligible. Tighten to <access origin="https://localhost/" /> for least-privilege.
  • Dockerfile — Nginx stage runs as root (no USER directive). nginx:alpine drops worker processes to nginx user by default but the master process stays root. Switch to rootless nginx (port 8080, USER nginx).
  • Dev mode CSP (vite.config.web.ts:29) — script-src includes 'unsafe-inline' in development only (needed for Vite HMR). Acceptable because dev runs on localhost; noted for awareness.
  • tools/forensic/fetch-video-fixtures.sh, docker/android-builder/Dockerfile — external artifacts fetched without hash verification. The cmdline-tools zip is pinned by version but not digest. Add sha256sum verification.

Positive Findings

  • Zero outbound network in production: CSP connect-src 'self' enforced identically across all three layers (meta tag, nginx, CF Pages _headers). No outbound fetch(), XMLHttpRequest, sendBeacon(), or WebSocket call exists in src/ pointing at any external origin.
  • ZIP bomb protection: zip_strategy.ts implements a nesting-depth cap (MAX_NESTING_DEPTH = 10) and a shared cross-level decompression budget (MAX_DECOMPRESSED_BYTES = 2 GB) with a CDH pre-allocation size check. The shared budget correctly prevents "10 × cap" circumvention via per-level counters.
  • Android backup disabled: AndroidManifest.xml:5-6 sets android:allowBackup="false" and android:fullBackupContent="false", backed by a comprehensive data_extraction_rules.xml excluding all storage domains for both cloud-backup and device-transfer.
  • APK signing secrets handled via tmpfile + shred: scripts/build-apk-local.sh:193-196 writes secrets to a mktemp 0600 file and shreds it via trap … EXIT INT TERM, preventing secrets from appearing in ps auxe or docker inspect output.
  • lastModified: 0 on all download files: browser_file_bytes.ts:25 explicitly sets lastModified: 0 on every output File object, implementing privacy invariant §6 (no timestamp leakage through the download path).
  • Settings validation at deserialization boundary: settings_schema.ts:130-161 validates all localStorage-sourced settings through typed guards with safe defaults, using Object.create(null) to avoid prototype pollution on the parsed object.
  • No unsafe DOM patterns in source: grep across all production TypeScript files found zero occurrences of eval, raw innerHTML assignment, or React's raw-HTML injection prop.
  • Encrypted ZIP detection: zip_strategy.ts:544-605 scans the Central Directory for ZipCrypto (flag bit 0), strong encryption (flag bit 6), and WinZip AES method 99 — more thorough than JSZip's built-in check which only catches flag bit 0.
  • usesCleartextTraffic="false" on Android: blocks any accidental HTTP traffic at the OS layer, independent of the CSP.
  • Google Services removed from Gradle: the Capacitor-default com.google.gms.google-services plugin reference is deleted entirely from android/app/build.gradle, keeping ./gradlew dependencies and aapt dump permissions clean.

Remediation Plan

Priority Action Effort Status
1 Replace .env with password-manager retrieval for APK signing secret 12 h open
2 Wire yarn audit --level high into CI as a failing gate 30 min shipped 2026-05-23 (PR #196)
3 Upgrade madge and @capacitor/cli to resolve critical/high advisory chains 1 h shipped 2026-05-23 (PR #196 — 42 → 0 findings)
4 Tighten style-src — remove 'unsafe-inline' from all CSP policies 24 h shipped 2026-05-23 (PR #197)
5 Add HSTS header to nginx.conf (behind TLS guard / comment) 15 min open
6 Tighten Capacitor config.xml to <access origin="https://localhost/" /> 5 min open
7 Run rootless nginx in Dockerfile (port 8080, USER nginx) 30 min open
8 Add sha256 verification for commandlinetools-linux-…_latest.zip in Android builder Dockerfile 30 min open

Dependency Note

A real-time SCA tool (Snyk, Dependabot, OSV-Scanner, yarn npm audit, etc.) should be run to catch CVEs published after my training cutoff. Versions flagged from yarn audit output (run 2026-05-22):

  • minimist < 1.2.6 — Prototype Pollution (critical) — via madge > dependency-tree > precinct (two paths). Advisory: npm/1097678. Resolved 2026-05-23 (PR #196).
  • ansi-regex < 5.0.1 — Inefficient RegExp Complexity / ReDoS (high) — via @capacitor/cli (three paths). Advisory: npm/1094092. Resolved 2026-05-23 (PR #196).
  • brace-expansion (moderate) — Large numeric range defeats DoS protection — via madge > precinct > detective-vue2 > minimatch. Advisory: npm/1119088. Resolved 2026-05-23 (PR #196).
  • All flagged packages were devDependencies; none reached the production browser bundle.
  • yarn audit post-fix (2026-05-23): 0 findings.