Resolve security audit findings
ober
e4a0ba000c8f1759b8f2dc307f6e73ebd730032f
--- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: build-and-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Install system tools run: | @@ -30,14 +30,14 @@ jobs: sudo apt-get install -y --no-install-recommends build-essential git curl ca-certificates libssl-dev - name: Install Jerboa toolchain - run: sh support/ensure-jerboa.sh "$JERBOA_VERSION" .jerboa/bin + run: sh support/ensure-jerboa.sh "$JERBOA_VERSION" .jerboa/bin support/jerboa-toolchain.lock - name: Fetch dependencies run: | set -eu mkdir -p .deps - git clone --depth 1 "$JERBOA_SSL_REPO" "$JERBOA_SSL_DIR" - git clone --depth 1 "$JERBOA_MAIL_REPO" "$JERBOA_MAIL_DIR" + support/locked-dependency.sh fetch dependencies.lock jerboa-ssl "$JERBOA_SSL_DIR" + support/locked-dependency.sh fetch dependencies.lock jerboa-mail "$JERBOA_MAIL_DIR" - name: Release evidence run: make release-evidence --- a/.github/workflows/security-baseline.yml +++ b/.github/workflows/security-baseline.yml @@ -13,7 +13,7 @@ jobs: baseline: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Required release files run: | @@ -33,3 +33,6 @@ jobs: echo "High-confidence secret pattern found." exit 1 fi + + - name: Security policy regression gate + run: make security --- a/.jerboa/security.json +++ b/.jerboa/security.json @@ -12,9 +12,22 @@ "policy": { "failOn": ["critical", "high"], "imports": { "directChezscheme": "deny" }, - "ffi": { "allowed": false }, + "ffi": { + "allowed": "reviewed-secure-open-only", + "requiredConvention": "open-varargs-after-2" + }, "process": { "shellInterpolation": "deny" }, - "network": { "requireTimeouts": true, "rawMailOutputEvidence": "deny" }, + "network": { + "requireTimeouts": true, + "rawMailOutputEvidence": "deny", + "defaultTransport": "tls-fail-closed", + "plaintextTransport": "literal-loopback-explicit-only", + "maxImapLineBytes": 16384, + "maxImapLiteralBytes": 33554432, + "maxImapResponseBytes": 67108864, + "maxImapResponseEntries": 4096, + "responseDeadlineMilliseconds": 60000 + }, "eval": { "stringEval": "deny", "bareRead": "deny", "allowReadEval": false }, "credentials": { "plaintextCache": "deny", @@ -25,7 +38,15 @@ "mail": { "defaultMutations": "deny", "requireBodyPeek": true, - "plaintextExportEvidence": "target-required" + "plaintextExportEvidence": "target-required", + "plaintextExportMode": "exclusive-nofollow-0600-atomic-publish", + "terminalRendering": "control-filtered", + "rawTerminalOutput": "explicit-opt-in-only" + }, + "supplyChain": { + "dependencyLock": "dependencies.lock", + "toolchainLock": "support/jerboa-toolchain.lock", + "githubActions": "full-commit-sha-only" }, "targetEvidence": { "bridgeProof": "fail-closed", --- a/Makefile +++ b/Makefile @@ -1,5 +1,7 @@ JERBOA_VERSION ?= v0.2.3 JERBOA_TOOL_DIR ?= $(CURDIR)/.jerboa/bin +JERBOA_TOOLCHAIN_LOCK ?= $(CURDIR)/support/jerboa-toolchain.lock +DEPENDENCY_LOCK ?= $(CURDIR)/dependencies.lock JERBUILD ?= $(shell if [ -x "$(CURDIR)/jerbuild" ]; then echo "$(CURDIR)/jerbuild"; \ elif [ -x "$(JERBOA_TOOL_DIR)/jerbuild" ]; then echo "$(JERBOA_TOOL_DIR)/jerbuild"; \ elif [ -x "$(CURDIR)/../jerboa/dist/jerbuild" ]; then echo "$(CURDIR)/../jerboa/dist/jerbuild"; \ @@ -28,7 +30,7 @@ else endif LD_ENV = $(LD_VAR)="$(JERBOA_SSL_DIR)" -.PHONY: help ensure-jerboa-tools ensure-deps deps build run test integration-test doctor verify security sanitize-evidence dependency-evidence sbom reproducibility-report target-evidence target-evidence-selftest release-evidence clean +.PHONY: help ensure-jerboa-tools dependency-lock-check ensure-deps deps build run test integration-test doctor verify security sanitize-evidence dependency-evidence sbom reproducibility-report target-evidence target-evidence-selftest release-evidence clean .DEFAULT_GOAL := help help: @@ -59,16 +61,21 @@ ensure-jerboa-tools: echo "=== Using Jerboa toolchain from PATH: $$(command -v jerbuild) ==="; \ else \ echo "=== Fetching Jerboa $(JERBOA_VERSION) release tools into $(JERBOA_TOOL_DIR) ==="; \ - sh support/ensure-jerboa.sh "$(JERBOA_VERSION)" "$(JERBOA_TOOL_DIR)"; \ + sh support/ensure-jerboa.sh "$(JERBOA_VERSION)" "$(JERBOA_TOOL_DIR)" "$(JERBOA_TOOLCHAIN_LOCK)"; \ fi @"$(JERBUILD)" --jerboa-home >/dev/null || { \ echo "ERROR: Jerboa toolchain is unavailable; set JERBUILD=/path/to/jerbuild or JERBOA_VERSION=<tag>" >&2; \ exit 1; \ } -ensure-deps: +dependency-lock-check: + @support/locked-dependency.sh check "$(DEPENDENCY_LOCK)" + +ensure-deps: dependency-lock-check @test -d "$(JERBOA_SSL_DIR)" || { echo "ERROR: JERBOA_SSL_DIR missing: $(JERBOA_SSL_DIR)" >&2; exit 1; } @test -d "$(JERBOA_MAIL_DIR)" || { echo "ERROR: JERBOA_MAIL_DIR missing: $(JERBOA_MAIL_DIR)" >&2; exit 1; } + @support/locked-dependency.sh verify "$(DEPENDENCY_LOCK)" jerboa-ssl "$(JERBOA_SSL_DIR)" >/dev/null + @support/locked-dependency.sh verify "$(DEPENDENCY_LOCK)" jerboa-mail "$(JERBOA_MAIL_DIR)" >/dev/null deps: ensure-jerboa-tools ensure-deps @if [ ! -f "$(JERBOA_SSL_DIR)/lib/jerboa-ssl.sls" ] || [ ! -f "$(JERBOA_SSL_DIR)/jerboa_ssl_shim$(SO_EXT)" ]; then \ @@ -78,7 +85,7 @@ deps: ensure-jerboa-tools ensure-deps build: deps run: build - JERBOA_SSL_LIB="$(JERBOA_SSL_DIR)" $(LD_ENV) \ + JERBOA_SSL_LIB="$(JERBOA_SSL_DIR)" JERBOA_SSL_ALLOW_DYNAMIC_NATIVE=1 $(LD_ENV) \ "$(JERBUILD)" exec --libdirs "$(LIBDIRS)" main.ss -- $(ARGS) doctor: @@ -86,7 +93,7 @@ doctor: test: build JERBOA_SSL_LIB="$(JERBOA_SSL_DIR)" $(LD_ENV) \ - "$(JERBUILD)" exec --libdirs "$(LIBDIRS)" test/test-all.ss + JERBUILD="$(JERBUILD)" LIBDIRS="$(LIBDIRS)" sh test/run-tests.sh integration-test: @if [ "$$PROTON_BRIDGE_INTEGRATION" != "1" ]; then \ @@ -111,12 +118,16 @@ security: grep -q 'private_path_scan_status=pass' Makefile; \ grep -q 'target-bridge-proof-contains-sensitive-material' scripts/target-evidence.sh; \ grep -q 'proofMaterialPolicy' .jerboa/security.json; \ - echo "secret_scan_status=pass" + echo "secret_scan_status=pass"; \ + sh scripts/security-check.sh sanitize-evidence: @sh scripts/sanitize-evidence.sh "$(RELEASE_EVIDENCE_DIR)" dependency-evidence: deps + @mkdir -p "$(RELEASE_EVIDENCE_DIR)" + @support/locked-dependency.sh verify "$(DEPENDENCY_LOCK)" jerboa-ssl "$(JERBOA_SSL_DIR)" > "$(RELEASE_EVIDENCE_DIR)/jerboa-ssl-lock.txt" + @support/locked-dependency.sh verify "$(DEPENDENCY_LOCK)" jerboa-mail "$(JERBOA_MAIL_DIR)" > "$(RELEASE_EVIDENCE_DIR)/jerboa-mail-lock.txt" $(MAKE) -C "$(JERBOA_SSL_DIR)" JERBUILD="$(JERBUILD)" release-evidence $(MAKE) -C "$(JERBOA_MAIL_DIR)" JERBUILD="$(JERBUILD)" release-evidence @@ -165,6 +176,8 @@ release-evidence: @$(MAKE) run ARGS='--help' > "$(RELEASE_EVIDENCE_DIR)/help.log" 2>&1 @echo "==> Running dependency release evidence" @$(MAKE) dependency-evidence > "$(RELEASE_EVIDENCE_DIR)/dependency-evidence.log" 2>&1 + @cp "$(DEPENDENCY_LOCK)" "$(RELEASE_EVIDENCE_DIR)/dependencies.lock" + @cp "$(JERBOA_TOOLCHAIN_LOCK)" "$(RELEASE_EVIDENCE_DIR)/jerboa-toolchain.lock" @echo "==> Running reproducibility report" @$(MAKE) reproducibility-report > "$(RELEASE_EVIDENCE_DIR)/reproducibility.log" 2>&1 @rm -rf "$(RELEASE_EVIDENCE_DIR)/reproducibility" @@ -179,7 +192,7 @@ release-evidence: @$(MAKE) sbom > "$(RELEASE_EVIDENCE_DIR)/sbom.log" 2>&1 @rm -rf "$(RELEASE_EVIDENCE_DIR)/sbom" @cp -R "$(DIST_DIR)/sbom" "$(RELEASE_EVIDENCE_DIR)/sbom" - @find bin protonmail test docs support scripts .github .jerboa/security.json -type f -print | LC_ALL=C sort | xargs shasum -a 256 > "$(RELEASE_EVIDENCE_DIR)/source-hashes.sha256" + @find bin protonmail test docs support scripts .github .jerboa/security.json dependencies.lock Makefile README.md SECURITY.md plan.md main.ss -type f -print | LC_ALL=C sort | xargs shasum -a 256 > "$(RELEASE_EVIDENCE_DIR)/source-hashes.sha256" @if ! grep -qx 'status=match' "$(RELEASE_EVIDENCE_DIR)/reproducibility/report.txt"; then \ echo "reproducibility_match_status=fail" > "$(RELEASE_EVIDENCE_DIR)/reproducibility-match.status"; \ { echo "release_evidence_status=blocked"; echo "blocked_reason=reproducibility-mismatch"; echo "evidence_privacy_status=not-run"; echo "private_path_scan_status=not-run"; } > "$(RELEASE_EVIDENCE_DIR)/result.txt"; \ --- a/README.md +++ b/README.md @@ -72,10 +72,15 @@ export PROTON_BRIDGE_HOST=127.0.0.1 export PROTON_BRIDGE_PORT=1143 export PROTON_BRIDGE_USER='bridge-generated-user' export PROTON_BRIDGE_PASSWORD='bridge-generated-password' -export PROTON_BRIDGE_TLS=auto +export PROTON_BRIDGE_TLS=loopback-plain ``` Use Bridge-generated IMAP credentials, not your Proton account password. +The default transport is `tls` and certificate or handshake failures are fatal. +Because the common Bridge listener on port 1143 is plaintext, it requires the +explicit mode `loopback-plain`; that mode accepts only literal `127.0.0.1` or +`::1`. There is no opportunistic plaintext fallback and DNS names are rejected +for plaintext. ## Commands @@ -84,7 +89,7 @@ bin/protonmail-read doctor bin/protonmail-read folders bin/protonmail-read list --folder INBOX --limit 20 bin/protonmail-read show --folder INBOX --uid 123 -bin/protonmail-read raw --folder INBOX --uid 123 +bin/protonmail-read raw --folder INBOX --uid 123 > message.eml bin/protonmail-read export-eml --folder INBOX --uid 123 --output message.eml bin/protonmail-read search --folder INBOX --from person@example.com bin/protonmail-read search --folder INBOX --subject invoice @@ -98,7 +103,18 @@ messages as read. The tool does not issue `STORE`, `EXPUNGE`, `COPY`, `MOVE`, `APPEND`, `CREATE`, or `DELETE`. Exported `.eml` files contain plaintext mail after Bridge has decrypted it -locally. Treat them as sensitive. +locally. `export-eml` refuses existing destinations, symlinks, and untrusted +group/world-writable parent directories; it creates a verified `0600` file and +publishes complete content atomically. Treat exports as sensitive. + +Normal folder, summary, header, body, greeting, and error output filters ANSI, +OSC, C0/C1, DEL, and bidirectional terminal controls. `raw` is intentionally +unfiltered for exact RFC 5322 output and refuses a terminal unless the user +passes `--unsafe-terminal`; piping it is preferred. + +IMAP input is bounded before allocation: 16 KiB per line, 32 MiB per message +literal, 64 MiB per command response, 4096 response entries, 64 literals, and a +60-second total response deadline in addition to socket idle timeouts. Credential and plaintext-mail handling are documented in [`docs/credential-handling.md`](docs/credential-handling.md). Dependency --- a/SECURITY.md +++ b/SECURITY.md @@ -16,6 +16,9 @@ experimental until the release checklist in `~/Release-plan.md` and - Use Proton Bridge generated IMAP credentials, not Proton account passwords. - Keep Bridge bound to loopback. Do not expose the Bridge IMAP listener on a shared or remote interface. +- TLS is the fail-closed default. Plaintext requires the explicit + `loopback-plain` mode and literal `127.0.0.1` or `::1`; the client never falls + back from failed TLS to credential-bearing plaintext. - Do not commit Bridge usernames, Bridge passwords, `.env` files, exported `.eml`/`.mbox` messages, mailbox metadata, private hostnames, or user data. - Treat raw messages, decoded message bodies, headers, attachment filenames, @@ -25,6 +28,12 @@ experimental until the release checklist in `~/Release-plan.md` and mutation commands without a threat model and tests. - Diagnostics must redact credentials and avoid printing message bodies unless the user explicitly requested `show`, `raw`, or `export-eml`. +- Normal terminal output strips terminal controls. Exact `raw` output is an + explicit byte-oriented mode and requires `--unsafe-terminal` on a TTY. +- IMAP lines, literals, aggregate response bytes, response/literal counts, and + total response time are bounded before allocation or accumulation. +- `.eml` export never overwrites. It requires a trusted parent directory and + uses exclusive/no-follow `0600` files plus verified atomic publication. - Production release requires marker-complete live Proton Bridge target evidence through `JPROTONMAIL_TARGET_PROOF_FILE`; setting `JPROTONMAIL_REQUIRE_TARGET_PROOF=1` makes missing or incomplete proof fail @@ -37,6 +46,8 @@ experimental until the release checklist in `~/Release-plan.md` and smoke, high-confidence secret scan, sibling `jerboa-ssl` and `jerboa-mail` release-evidence gates, SBOM/dependency capture, and launcher/source reproducibility checks. +- CI actions, Jerboa toolchain archives, and the `jerboa-ssl`/`jerboa-mail` + checkouts are pinned by immutable SHA or commit-and-tree locks. Credential and plaintext-mail details are documented in [`docs/credential-handling.md`](docs/credential-handling.md). --- a/bin/protonmail-read +++ b/bin/protonmail-read @@ -5,6 +5,17 @@ repo_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) jerboa_ssl_dir=${JERBOA_SSL_DIR:-"$repo_dir/../jerboa-ssl"} jerboa_mail_dir=${JERBOA_MAIL_DIR:-"$repo_dir/../jerboa-mail"} +[ -d "$jerboa_ssl_dir" ] || { + echo "ERROR: JERBOA_SSL_DIR is not a directory" >&2 + exit 1 +} +[ -d "$jerboa_mail_dir" ] || { + echo "ERROR: JERBOA_MAIL_DIR is not a directory" >&2 + exit 1 +} +jerboa_ssl_dir=$(CDPATH= cd -P -- "$jerboa_ssl_dir" && pwd) +jerboa_mail_dir=$(CDPATH= cd -P -- "$jerboa_mail_dir" && pwd) + if [ "${JERBUILD:-}" ]; then jerbuild=$JERBUILD elif [ -x "$repo_dir/.jerboa/bin/jerbuild" ]; then @@ -22,7 +33,8 @@ fi jerboa_home=$("$jerbuild" --jerboa-home) -export JERBOA_SSL_LIB=${JERBOA_SSL_LIB:-"$jerboa_ssl_dir"} +export JERBOA_SSL_LIB="$jerboa_ssl_dir" +export JERBOA_SSL_ALLOW_DYNAMIC_NATIVE=1 exec "$jerbuild" exec --libdirs "$repo_dir:$jerboa_mail_dir:$jerboa_ssl_dir/lib:$jerboa_home/lib" \ "$repo_dir/main.ss" -- "$@" new file mode 100644 --- /dev/null +++ b/dependencies.lock @@ -0,0 +1,3 @@ +# name repository commit tree +jerboa-ssl https://git.sr.ht/~lisp/jerboa-ssl 270b858534941baad6420e4906a26c279f4a8678 4d629174bbff9b1cd2ff1aa696be0cd33bf462e4 +jerboa-mail https://git.sr.ht/~lisp/jerboa-mail 5d6382371decb5ce93016b46a1710d65255401b0 48b15dbdb9e18749b6195f1cbbd7d2804c54b6a6 --- a/docs/credential-handling.md +++ b/docs/credential-handling.md @@ -13,7 +13,7 @@ PROTON_BRIDGE_HOST=127.0.0.1 PROTON_BRIDGE_PORT=1143 PROTON_BRIDGE_USER=bridge-generated-user PROTON_BRIDGE_PASSWORD=bridge-generated-password -PROTON_BRIDGE_TLS=auto +PROTON_BRIDGE_TLS=loopback-plain ``` Use Bridge generated IMAP credentials only. Do not use a Proton account @@ -27,6 +27,11 @@ chmod 0600 .protonmail-reader.local If a future config-file loader is added, it must reject group-readable or world-readable files when they contain a password. +The configuration default is authenticated TLS and fails closed. Use +`loopback-plain` only for a Bridge listener addressed literally as `127.0.0.1` +or `::1`. `auto`, generic `plain`, DNS names, and TLS-to-plaintext fallback are +rejected so a valid hostile greeting can never downgrade credential transport. + ## Logging And Output The `doctor` command redacts the Bridge username and password. Error messages @@ -36,9 +41,13 @@ mailbox responses that may contain message content. The `show`, `raw`, and `export-eml` commands intentionally disclose decrypted mail selected by the user. Treat their output as sensitive: -- stdout may be captured by shell history wrappers, terminal logs, or calling - programs. +- stdout may be captured by terminal logs or calling programs. Friendly output + filters terminal controls; exact `raw` bytes do not and are refused on a TTY + without `--unsafe-terminal`. - `.eml` exports are plaintext decrypted messages. +- `export-eml` creates a verified private `0600` file, refuses existing names + and symlinks, requires an owner-controlled parent, and atomically publishes + only complete content. - headers can reveal correspondents, subjects, dates, and mailbox structure. - raw IMAP literals can contain attachments and embedded secrets. --- a/docs/dependency-provenance.md +++ b/docs/dependency-provenance.md @@ -7,7 +7,7 @@ Dependency boundaries: - Proton Mail Bridge performs Proton authentication and local message decryption. -- `jerboa-ssl` provides the local TLS transport when TLS is enabled. +- `jerboa-ssl` provides the fail-closed TLS transport and bounded native reads. - `jerboa-mail` parses and decodes headers, MIME structure, and friendly message output. - This repository reads Bridge settings from environment variables and does not @@ -17,3 +17,9 @@ Dependency boundaries: `jerboa-ssl` and `jerboa-mail` checkouts, then records their git state, hashes, OpenSSL advisory evidence, TLS reproducibility result, and mail parser dependency audit evidence in the local SBOM bundle. + +`dependencies.lock` pins both sibling repositories by exact commit and Git tree +hash. CI fetches those commits directly and verifies the resulting trees. +`support/jerboa-toolchain.lock` pins SHA-256 digests for every supported v0.2.3 +toolchain archive; the bootstrap no longer trusts a checksum downloaded beside +the executable. GitHub Actions are pinned to full reviewed commit SHAs. --- a/docs/release-evidence.md +++ b/docs/release-evidence.md @@ -7,9 +7,12 @@ The evidence bundle contains: - high-confidence tracked-file secret-scan output; - smoke-test output; +- hostile framing, terminal-control, symlink/export, permission, and boundary + regression output; - CLI help smoke output; - sibling `jerboa-ssl` and `jerboa-mail` release-evidence logs; - SBOM-style source, workflow, launcher, dependency, and toolchain manifests; +- exact dependency lock verification and the checked-in toolchain hash lock; - copied `jerboa-ssl` OpenSSL advisory and reproducibility evidence when available; - copied `jerboa-mail` dependency audit evidence when available; new file mode 100644 --- /dev/null +++ b/docs/threat-model.md @@ -0,0 +1,31 @@ +# Threat Model + +`jerboa-protonmail` treats the configured IMAP endpoint, every message, every +mailbox/header field, and every output pathname as untrusted. Bridge-generated +credentials and decrypted message bytes are secrets. + +Security boundaries: + +- TLS is the default and fails closed. The only plaintext exception is the + explicit `loopback-plain` mode for literal `127.0.0.1` or `::1`; credentials + are never sent after a failed TLS attempt. +- Socket idle timeouts and a monotonic command deadline bound slow peers. IMAP + line, token, nesting, literal, response-entry, literal-count, and cumulative + response limits are checked before allocation or list growth. +- Friendly terminal rendering removes CSI, OSC, DCS/SOS/PM/APC, C0/C1, DEL, + and bidirectional override/isolate controls. Exact raw RFC 5322 output is a + separate explicit mode and is not silently rendered to a terminal. +- Plaintext export requires an owner-controlled parent that is not + group/world-writable. Random temporary and destination-reservation files use + exclusive, no-follow, close-on-exec creation at mode `0600`; device/inode, + type, owner, link count, size, mode, and parent identity are verified around + atomic publication. Existing destinations are never overwritten. +- `dependencies.lock`, the Jerboa toolchain digest lock, and full GitHub Action + SHAs define the reviewed build graph. + +Out of scope for local evidence are compromise of the Proton account or Bridge +process, a hostile actor already running as the same OS user with access to the +Bridge credentials, kernel/filesystem compromise, certificate-authority +compromise, and availability after the documented bounded limits are reached. +Production claims additionally require marker-complete live target evidence; +mail or credentials must never be retained in that evidence. --- a/plan.md +++ b/plan.md @@ -167,7 +167,7 @@ The binary can be named `protonmail-read`. protonmail-read folders protonmail-read list --folder INBOX --limit 20 protonmail-read show --folder INBOX --uid 12345 -protonmail-read raw --folder INBOX --uid 12345 +protonmail-read raw --folder INBOX --uid 12345 > message.eml protonmail-read search --folder INBOX --from someone@example.com protonmail-read search --folder INBOX --subject invoice protonmail-read search --folder INBOX --since 2026-01-01 @@ -184,7 +184,7 @@ PROTON_BRIDGE_HOST=127.0.0.1 PROTON_BRIDGE_PORT=1143 PROTON_BRIDGE_USER=bridge-generated-user PROTON_BRIDGE_PASSWORD=bridge-generated-password -PROTON_BRIDGE_TLS=auto +PROTON_BRIDGE_TLS=loopback-plain ``` Optional later local config path: @@ -198,6 +198,14 @@ Rules: - Add local config files to `.gitignore`. - Refuse to read config files that are group/world-readable if they contain a password. - Redact all secrets from diagnostics. +- Default to fail-closed TLS. Permit plaintext only as explicit + `loopback-plain` to literal `127.0.0.1` or `::1`; never fall back after a TLS + failure. +- Bound IMAP lines, literals, response counts, cumulative bytes, parser depth, + and total response time before allocation or accumulation. +- Filter terminal controls in friendly output and require explicit unsafe TTY + opt-in for exact raw mail bytes. +- Publish `.eml` only through exclusive/no-follow private atomic export. ## Architecture --- a/protonmail/cli.ss +++ b/protonmail/cli.ss @@ -7,6 +7,9 @@ (import (rnrs) (protonmail config) (protonmail imap client) + (protonmail secure-file) + (protonmail terminal) + (only (std os posix) posix-isatty) (jerboa-mail encoding) (jerboa-mail header) (jerboa-mail mime)) @@ -21,19 +24,23 @@ " folders List folders (phase 3)\n" " list --folder INBOX --limit N List messages (phase 3)\n" " show --folder INBOX --uid UID Decode one message (phase 5)\n" - " raw --folder INBOX --uid UID Print raw RFC 5322 message (phase 4)\n" - " export-eml --uid UID --output F Export raw message (phase 4)\n" + " raw --folder INBOX --uid UID Write unfiltered RFC 5322 bytes; a TTY requires --unsafe-terminal\n" + " export-eml --uid UID --output F Atomically create a private 0600 message file; never overwrites\n" " search --folder INBOX ... Search messages (phase 6)\n" " doctor Check local configuration\n" " version Print version\n" " help Print this help\n")) (define (println s) - (display s) + (display (terminal-safe-field s)) + (newline)) + + (define (println-field s) + (display (terminal-safe-field s)) (newline)) (define (die code msg) - (display msg (current-error-port)) + (display (terminal-safe-field msg) (current-error-port)) (newline (current-error-port)) (exit code)) @@ -71,7 +78,7 @@ (require-complete-config cfg) (let ([folders (run-network-command (lambda () (imap-list-folders cfg)))]) - (for-each println folders)))) + (for-each println-field folders)))) (define (option-ref args name default) (let loop ([xs args]) @@ -88,7 +95,7 @@ [n (and raw (string->number raw))]) (cond [(not raw) default] - [(and n (integer? n) (> n 0)) n] + [(and n (integer? n) (exact? n) (> n 0) (<= n 1000)) n] [else (die 2 (string-append "invalid number for " name))]))) (define (required-option args name) @@ -97,16 +104,22 @@ value (die 2 (string-append "missing required option " name))))) + (define (option-present? args name) + (let loop ([xs args]) + (and (pair? xs) + (or (string=? (car xs) name) + (loop (cdr xs)))))) + (define (print-summary-row s) (println (string-append - (assoc-value 'uid s "") + (terminal-safe-field (assoc-value 'uid s "")) "\t" - (assoc-value 'date s "") + (terminal-safe-field (assoc-value 'date s "")) "\t" - (assoc-value 'from s "") + (terminal-safe-field (assoc-value 'from s "")) "\t" - (assoc-value 'subject s "")))) + (terminal-safe-field (assoc-value 'subject s ""))))) (define (print-summary-table summaries) (println "UID\tDate\tFrom\tSubject") @@ -173,11 +186,6 @@ (put-bytevector p bv) (flush-output-port p))) - (define (write-file-bytes path bv) - (call-with-port (open-file-output-port path (file-options no-fail)) - (lambda (p) - (put-bytevector p bv)))) - (define (fetch-raw-from-args args) (let* ([cfg (config-from-environment)] [folder (option-ref args "--folder" "INBOX")] @@ -188,20 +196,26 @@ (imap-fetch-raw-message cfg folder uid))))) (define (cmd-raw args) + (when (and (posix-isatty 1) + (not (option-present? args "--unsafe-terminal"))) + (die 2 + "refusing to write unfiltered mail to a terminal; pipe it or pass --unsafe-terminal explicitly")) (write-stdout-bytes (fetch-raw-from-args args))) (define (cmd-export-eml args) (let* ([output (required-option args "--output")] - [message (fetch-raw-from-args args)]) - (write-file-bytes output message) - (println (string-append "wrote " output)))) + [message (fetch-raw-from-args args)] + [published (secure-export-bytes output message)]) + (println-field + (string-append "wrote private 0600 export " published)))) (define (decoded-header headers name) (mail-decode-encoded-words (mail-header-ref headers name ""))) (define (print-header-if-present label value) (when (> (string-length value) 0) - (println (string-append label ": " value)))) + (println-field + (string-append label ": " (terminal-safe-field value))))) (define (cmd-show args) (let* ([raw (fetch-raw-from-args args)] @@ -214,7 +228,7 @@ (print-header-if-present "Date" (mail-header-ref headers "Date" "")) (print-header-if-present "Subject" (decoded-header headers "Subject")) (newline) - (display (mail-best-text-body msg)) + (display (terminal-safe-text (mail-best-text-body msg))) (newline))) (define (cmd-doctor) --- a/protonmail/config.ss +++ b/protonmail/config.ss @@ -9,6 +9,7 @@ config-user config-password config-tls + config-loopback-host? config-from-environment config-complete? config-errors @@ -43,35 +44,51 @@ (if port-env (valid-port-string->number port-env) 1143) (or (env "PROTON_BRIDGE_USER") "") (or (env "PROTON_BRIDGE_PASSWORD") "") - (or (env "PROTON_BRIDGE_TLS") "auto")))) + (or (env "PROTON_BRIDGE_TLS") "tls")))) (define (nonempty? s) (and (string? s) (> (string-length s) 0))) + (define (config-loopback-host? host) + (and (string? host) + (or (string=? host "127.0.0.1") + (string=? host "::1")))) + + (define (safe-host? host) + (and (nonempty? host) + (let loop ([i 0]) + (cond + [(= i (string-length host)) #t] + [else + (let ([n (char->integer (string-ref host i))]) + (and (> n 32) (< n 127) (not (= n 127)) + (loop (+ i 1))))])))) + (define (valid-tls-mode? mode) (and (string? mode) - (or (string-ci=? mode "auto") - (string-ci=? mode "tls") - (string-ci=? mode "ssl") - (string-ci=? mode "plain") - (string-ci=? mode "true") - (string-ci=? mode "yes") - (string-ci=? mode "false") - (string-ci=? mode "no") - (string-ci=? mode "off")))) + (or (string-ci=? mode "tls") + (string-ci=? mode "loopback-plain")))) (define (config-errors cfg) (let ([errors '()]) - (unless (nonempty? (config-host cfg)) - (set! errors (cons "PROTON_BRIDGE_HOST is empty" errors))) - (unless (number? (config-port cfg)) + (unless (safe-host? (config-host cfg)) + (set! errors (cons "PROTON_BRIDGE_HOST must be a nonempty ASCII host without whitespace or controls" errors))) + (unless (and (integer? (config-port cfg)) + (exact? (config-port cfg)) + (<= 1 (config-port cfg) 65535)) (set! errors (cons "PROTON_BRIDGE_PORT must be an integer from 1 to 65535" errors))) (unless (nonempty? (config-user cfg)) (set! errors (cons "PROTON_BRIDGE_USER is unset" errors))) (unless (nonempty? (config-password cfg)) (set! errors (cons "PROTON_BRIDGE_PASSWORD is unset" errors))) (unless (valid-tls-mode? (config-tls cfg)) - (set! errors (cons "PROTON_BRIDGE_TLS must be auto, tls, or plain" errors))) + (set! errors (cons "PROTON_BRIDGE_TLS must be tls or loopback-plain" errors))) + (when (and (string? (config-tls cfg)) + (string-ci=? (config-tls cfg) "loopback-plain") + (not (config-loopback-host? (config-host cfg)))) + (set! errors + (cons "loopback-plain transport requires literal host 127.0.0.1 or ::1" + errors))) (reverse errors))) (define (config-complete? cfg) --- a/protonmail/imap/client.ss +++ b/protonmail/imap/client.ss @@ -13,10 +13,21 @@ (import (rnrs) (protonmail config) + (protonmail imap framing) (protonmail imap parser) (protonmail imap transport) (jerboa-mail header)) + (define imap-max-header-bytes 1048576) + (define imap-max-message-bytes 33554432) + (define imap-max-result-count 1000) + + (define header-imap-limits + (make-imap-limits 16384 imap-max-header-bytes 2097152 256 4 30000)) + + (define message-imap-limits + (make-imap-limits 16384 imap-max-message-bytes 35651584 256 4 60000)) + (define (make-client tr greeting) (vector tr greeting 0)) @@ -41,12 +52,18 @@ (string-append "A" (number->string n)))) (define (imap-quote s) + (unless (string? s) + (error 'imap-quote "value must be a string")) (call-with-string-output-port (lambda (out) (write-char #\" out) (let loop ([i 0]) (when (< i (string-length s)) (let ([ch (string-ref s i)]) + (let ([n (char->integer ch)]) + (when (or (< n 32) (= n 127)) + (error 'imap-quote + "IMAP quoted value contains a forbidden control character"))) (when (or (char=? ch #\\) (char=? ch #\")) (write-char #\\ out)) (write-char ch out) @@ -56,75 +73,70 @@ (define (imap-command c command) (map imap-entry-line (imap-command-entries c command))) - (define (make-imap-entry line response literal tail) - (vector 'imap-entry line response literal tail)) - - (define (imap-entry-line e) (vector-ref e 1)) - (define (imap-entry-response e) (vector-ref e 2)) - (define (imap-entry-literal e) (vector-ref e 3)) - - (define (imap-command-entries c command) + (define (imap-command-entries c command . options) (let* ([tag (next-tag c)] - [wire (string-append tag " " command)]) + [wire (string-append tag " " command)] + [limits (if (pair? options) (car options) default-imap-limits)] + [tr (client-transport c)]) (transport-write-line (client-transport c) wire) - (let loop ([entries '()]) - (let ([line (transport-read-line (client-transport c))]) - (let* ([response (imap-parse-line line)] - [literal-len (imap-response-literal-length response)] - [literal (if literal-len - (transport-read-bytes (client-transport c) literal-len) - #f)] - [tail (if literal-len - (transport-read-line (client-transport c)) - #f)] - [entry (make-imap-entry line response literal tail)] - [new-entries (cons entry entries)]) - (if (string-prefix? tag line) - (reverse new-entries) - (loop new-entries))))))) + (imap-read-response-entries + tag + (lambda (max-line deadline) + (transport-read-line tr max-line deadline)) + (lambda (length max-literal deadline) + (transport-read-bytes tr length max-literal deadline)) + limits))) (define (imap-final-ok? lines) (let ([line (last lines)]) (and line - (let loop ([i 0]) - (cond - [(> (+ i 3) (string-length line)) #f] - [(string=? " OK" (substring line i (+ i 3))) #t] - [else (loop (+ i 1))]))))) + (let ([response (guard (e [#t #f]) + (imap-parse-line line))]) + (and response + (eq? (imap-response-kind response) 'tagged) + (string? (imap-response-status response)) + (string-ci=? (imap-response-status response) "OK")))))) (define (require-ok who lines) (unless (imap-final-ok? lines) - (error who (if (null? lines) "no IMAP response" (last lines)))) + ;; Do not include hostile server text: a malicious listener could echo + ;; the LOGIN command and credentials into diagnostics. + (error who (if (null? lines) + "no IMAP response" + "IMAP command did not complete successfully"))) lines) (define (normalize-tls-mode mode) (cond - [(or (string=? mode "tls") (string=? mode "ssl") - (string=? mode "true") (string=? mode "yes")) - "tls"] - [(or (string=? mode "plain") (string=? mode "false") - (string=? mode "no") (string=? mode "off")) - "plain"] - [(string=? mode "auto") "auto"] - [else (error 'imap-connect "invalid TLS mode")])) + [(and (string? mode) (string-ci=? mode "tls")) "tls"] + [(and (string? mode) (string-ci=? mode "loopback-plain")) + "loopback-plain"] + [else + (error 'imap-connect + "transport mode must be tls or loopback-plain")])) (define (open-mode cfg mode) (let ([tr (transport-connect (config-host cfg) (config-port cfg) mode)]) - (let ([greeting (guard (e [#t (transport-close tr) (raise e)]) - (transport-read-line tr))]) - (make-client tr greeting)))) + (let ([greeting + (guard (e [#t (transport-close tr) (raise e)]) + (transport-read-line + tr + (imap-limits-max-line-bytes default-imap-limits) + (transport-make-deadline 10000)))]) + (let ([response (guard (e [#t (transport-close tr) (raise e)]) + (imap-parse-line greeting))]) + (unless (and (eq? (imap-response-kind response) 'untagged) + (string? (imap-response-name response)) + (string-ci=? (imap-response-name response) "OK")) + (transport-close tr) + (error 'imap-connect "Bridge returned an invalid IMAP greeting")) + (make-client tr greeting))))) (define (open-client cfg) (let ([mode (normalize-tls-mode (config-tls cfg))]) - (cond - [(string=? mode "auto") - (let ([plain-result (guard (e [#t e]) - (open-mode cfg "plain"))]) - (if (condition? plain-result) - (open-mode cfg "tls") - plain-result))] - [else - (open-mode cfg mode)]))) + ;; There is deliberately no opportunistic plaintext fallback. A TLS + ;; handshake or certificate failure is fatal. + (open-mode cfg mode))) (define (count-list-lines lines) (let loop ([xs lines] [n 0]) @@ -233,7 +245,7 @@ (char<=? ch #\9) (loop (+ i 1)))))))) - (define (fetch-literal-by-uid client uid fetch-item) + (define (fetch-literal-by-uid client uid fetch-item limits) (unless (decimal-uid? uid) (error 'imap-fetch "invalid decimal UID" uid)) (let* ([command (string-append @@ -241,20 +253,26 @@ uid " " fetch-item)] - [entries (imap-command-entries client command)] + [entries (imap-command-entries client command limits)] [lines (map imap-entry-line entries)]) (require-ok 'imap-fetch lines) - (let loop ([xs entries]) + (let loop ([xs entries] [found #f]) (cond - [(null? xs) (error 'imap-fetch "response did not include a literal")] + [(null? xs) + (if found + found + (error 'imap-fetch "response did not include a literal"))] [(imap-entry-literal (car xs)) - (imap-entry-literal (car xs))] - [else (loop (cdr xs))])))) + (if found + (error 'imap-fetch "response included multiple literals") + (loop (cdr xs) (imap-entry-literal (car xs))))] + [else (loop (cdr xs) found)])))) (define (fetch-header-by-uid client uid) (utf8->string (fetch-literal-by-uid client uid - "BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)]"))) + "BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)]" + header-imap-limits))) (define (summary-for-uid client uid) (let* ([header-text (fetch-header-by-uid client uid)] @@ -266,6 +284,10 @@ (cons 'subject (mail-header-ref headers "Subject" ""))))) (define (imap-list-message-summaries cfg folder limit) + (unless (and (integer? limit) (exact? limit) + (> limit 0) (<= limit imap-max-result-count)) + (error 'imap-list-message-summaries + "limit must be between 1 and 1000")) (imap-with-session cfg (lambda (client) (require-ok 'imap-select @@ -285,6 +307,10 @@ (loop (cdr xs) (string-append acc sep (car xs))))))) (define (imap-search-message-summaries cfg folder criteria limit) + (unless (and (integer? limit) (exact? limit) + (> limit 0) (<= limit imap-max-result-count)) + (error 'imap-search-message-summaries + "limit must be between 1 and 1000")) (imap-with-session cfg (lambda (client) (require-ok 'imap-select @@ -305,6 +331,6 @@ (require-ok 'imap-select (imap-command client (string-append "SELECT " (imap-quote folder)))) - (fetch-literal-by-uid client uid "BODY.PEEK[]")))) + (fetch-literal-by-uid client uid "BODY.PEEK[]" message-imap-limits)))) ) ;; end library new file mode 100644 --- /dev/null +++ b/protonmail/imap/framing.ss @@ -0,0 +1,157 @@ +#!chezscheme +;;; (protonmail imap framing) - bounded cumulative IMAP response reader. + +(library (protonmail imap framing) + (export + make-imap-limits + default-imap-limits + imap-limits-max-line-bytes + imap-limits-max-literal-bytes + imap-limits-max-response-bytes + imap-limits-max-entries + imap-limits-max-literals + imap-limits-deadline-milliseconds + imap-read-response-entries + imap-entry-line + imap-entry-response + imap-entry-literal + imap-entry-tail) + + (import (rnrs) + (protonmail imap parser) + (protonmail imap transport)) + + ;; max-line, max-literal, max-total, max-entries, max-literals, deadline-ms + (define (make-imap-limits max-line max-literal max-total + max-entries max-literals deadline-ms) + (for-each + (lambda (item) + (unless (and (integer? (cdr item)) (exact? (cdr item)) + (> (cdr item) 0)) + (error 'make-imap-limits + "all IMAP limits must be positive exact integers" + (car item)))) + (list (cons 'max-line max-line) + (cons 'max-literal max-literal) + (cons 'max-total max-total) + (cons 'max-entries max-entries) + (cons 'max-literals max-literals) + (cons 'deadline-ms deadline-ms))) + (when (> max-line max-total) + (error 'make-imap-limits "line limit exceeds aggregate response limit")) + (when (> max-literal max-total) + (error 'make-imap-limits "literal limit exceeds aggregate response limit")) + (vector 'imap-limits max-line max-literal max-total + max-entries max-literals deadline-ms)) + + (define (imap-limits-max-line-bytes limits) (vector-ref limits 1)) + (define (imap-limits-max-literal-bytes limits) (vector-ref limits 2)) + (define (imap-limits-max-response-bytes limits) (vector-ref limits 3)) + (define (imap-limits-max-entries limits) (vector-ref limits 4)) + (define (imap-limits-max-literals limits) (vector-ref limits 5)) + (define (imap-limits-deadline-milliseconds limits) (vector-ref limits 6)) + + (define default-imap-limits + (make-imap-limits + transport-default-max-line-bytes + transport-default-max-literal-bytes + 67108864 ; 64 MiB cumulative response + 4096 + 64 + 60000)) + + (define (make-imap-entry line response literal tail) + (vector 'imap-entry line response literal tail)) + + (define (imap-entry-line entry) (vector-ref entry 1)) + (define (imap-entry-response entry) (vector-ref entry 2)) + (define (imap-entry-literal entry) (vector-ref entry 3)) + (define (imap-entry-tail entry) (vector-ref entry 4)) + + (define (checked-add-total total addition maximum) + (unless (and (integer? addition) (exact? addition) (>= addition 0)) + (error 'imap-response "invalid response byte count")) + (when (> addition (- maximum total)) + (error 'imap-response "aggregate IMAP response exceeds configured limit")) + (+ total addition)) + + (define (checked-line read-line max-line deadline) + (let ([line (read-line max-line deadline)]) + (unless (string? line) + (error 'imap-response "line reader did not return a string")) + (when (> (string-length line) max-line) + (error 'imap-response "IMAP line exceeds configured limit")) + line)) + + (define (tagged-for? response tag) + (and (eq? (imap-response-kind response) 'tagged) + (string? (imap-response-tag response)) + (string=? (imap-response-tag response) tag))) + + ;; READ-LINE receives (max-line deadline). READ-BYTES receives + ;; (length max-literal deadline). The reader is callback-based so hostile + ;; framing can be tested without a live Bridge or native socket. + (define (imap-read-response-entries tag read-line read-bytes limits) + (unless (and (string? tag) (> (string-length tag) 0)) + (error 'imap-response "expected a nonempty command tag")) + (unless (and (procedure? read-line) (procedure? read-bytes)) + (error 'imap-response "response readers must be procedures")) + (let* ([max-line (imap-limits-max-line-bytes limits)] + [max-literal (imap-limits-max-literal-bytes limits)] + [max-total (imap-limits-max-response-bytes limits)] + [max-entries (imap-limits-max-entries limits)] + [max-literals (imap-limits-max-literals limits)] + [deadline + (transport-make-deadline + (imap-limits-deadline-milliseconds limits))]) + (let loop ([entries '()] [entry-count 0] + [literal-count 0] [total 0]) + (when (transport-deadline-expired? deadline) + (error 'imap-response "total IMAP response deadline exceeded")) + (when (>= entry-count max-entries) + (error 'imap-response "IMAP response entry count exceeds configured limit")) + (let* ([line (checked-line read-line max-line deadline)] + [line-total (checked-add-total total