From ce52915d16614523e25fc518055344bf706defcb Mon Sep 17 00:00:00 2001 From: Dat Date: Sat, 18 Jul 2026 11:58:47 +0700 Subject: [PATCH] fix: harden secret redaction boundary --- ARCHITECTURE.md | 207 +++---------- README.md | 103 +++---- SECURITY.md | 82 ++---- bun.lock | 73 ----- package-lock.json | 4 +- package.json | 4 +- src/cli.ts | 6 +- src/commands/install.ts | 69 ++++- src/proxy/cert.ts | 42 +-- src/proxy/daemon.ts | 5 +- src/proxy/http.ts | 78 ++++- src/proxy/masker.ts | 430 +++++---------------------- src/proxy/server.ts | 187 ++---------- src/proxy/sse.ts | 303 ++----------------- src/utils.ts | 2 +- test/http.test.js | 56 +++- test/install.test.js | 6 +- test/masker.test.js | 217 ++------------ test/sse.test.js | 635 +++------------------------------------- 19 files changed, 504 insertions(+), 2005 deletions(-) delete mode 100644 bun.lock diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8056090..751d99a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,185 +2,64 @@ ## Overview -dotmask is a local HTTPS proxy that intercepts AI tool traffic and masks secrets before they leave the machine. - -## Core Components - -### 1. CLI (`src/cli.ts`) - -Entry point for all user-facing commands: - -- `install` / `uninstall` / `status` / `doctor` -- `allow` / `disallow` / `hosts` - -### 2. Proxy Server (`src/proxy/server.ts`) - -The core MITM proxy that handles: - -- HTTPS CONNECT tunnel establishment -- Per-hostname certificate generation -- Request/response masking -- SSE streaming support - -Key flow: - +Dotmask is a local, one-way HTTPS redaction proxy for allowlisted AI API hosts. Its invariant is: + +> Provider-controlled responses must never cause a real secret to be materialized. + +## Request flow + +```text +Claude Code + │ HTTPS_PROXY + NODE_EXTRA_CA_CERTS + ▼ +127.0.0.1 dotmask proxy + │ + ├─ non-allowlisted host → ordinary CONNECT passthrough + │ + └─ allowlisted host → local TLS termination + │ + ├─ parse complete HTTP/1.1 request + ├─ reject compressed/binary/unsupported bodies + ├─ recursively redact supported request content + ├─ validate upstream TLS normally + └─ forward provider response byte-for-byte ``` -Client connects to proxy - │ - ▼ - handleConnect() - │ - ├─► shouldMitmHost() → check allowed hosts - │ - ├─► Passthrough: non-allowed hosts bypass proxy - │ - └─► MITM Mode: - │ - ├─► Generate host certificate (signed by dotmask CA) - ├─► Parse HTTP request from TLS stream - ├─► maskRequestBody() - mask secrets in body - ├─► Forward to upstream with fake secrets - ├─► Receive response - └─► unmaskText() - restore real secrets -``` - -### 3. Masker (`src/proxy/masker.ts`) - -The core secret detection and masking logic. -#### Detection Methods +## Components -1. **Known Token Patterns** (`KNOWN_TOKEN_RE`): - - JWT: `eyJ[A-Za-z0-9\-_]{10,}\.[A-Za-z0-9\-_]{10,}\.[A-Za-z0-9\-_]{20,}` - - AWS Key ID: `AKIA[A-Z0-9]{16}` - - Stripe: `sk_live_...`, `sk_test_...` - - Anthropic: `sk-ant-api\d{2}-[A-Za-z0-9\-_+/]{20,}` - - OpenAI: `sk-proj-[A-Za-z0-9\-_+/]{20,}` - - And more... +### CLI and installation -2. **High Entropy Detection** (`isHighEntropySecret()`): - - Shannon entropy >= 3.5 - - Length >= 20 chars - - Character set matches Base64/Hex patterns +`src/cli.ts` dispatches installation, status, host-management, doctor, and uninstall commands. `src/commands/install.ts` manages Claude Code's `HTTPS_PROXY` and `NODE_EXTRA_CA_CERTS` settings. -3. **Environment Variable Assignment** (`SECRET_KEY_RE`): - - Keys matching: `KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|PRIVATE|AUTH` - - Values >= 16 chars +The CA is not added as a system-trusted root. The CA private key is stored at `~/.dotmask/ca/ca.key.pem` with mode `0600`, and the CA directory uses mode `0700`. -#### Format-Preserving Fake Generation - -```typescript -makeFake(real: string): string -``` +### Proxy server -1. Extract prefix (known prefixes like `sk-ant-api03-`) -2. Detect charset (Base64, Hex, Alphanumeric, etc.) -3. Generate deterministic random string of same length -4. Return prefix + fake +`src/proxy/server.ts` accepts connections only on `127.0.0.1`. Exact allowlisted hostnames are MITM-intercepted; other hosts are tunneled without inspection. Upstream connections use Node TLS validation with the target hostname as SNI. -Uses seed from SHA-256 hash of original value for reproducibility. +Responses are intentionally opaque to dotmask and are never rewritten. -#### Keychain Integration +### HTTP sanitization -- **Storage**: macOS Keychain with service `dotmask` -- **Account**: Fake key value -- **Password**: Real key value +`src/proxy/http.ts` parses request framing and implements fail-closed body handling: -Cache in memory with 30s TTL to avoid excessive Keychain calls. - -### 4. Certificate Management (`src/proxy/cert.ts`) - -CA certificate lifecycle: - -``` -First run: - └─► Generate RSA-4096 CA key - └─► Generate CA cert (valid 10 years) - └─► Install to macOS Keychain - └─► Trust certificate system-wide - -Per-host certificates: - └─► Generate RSA-2048 host key pair - └─► Create CSR with hostname as CN - └─► Sign with dotmask CA - └─► Cache in memory -``` - -### 5. Daemon Management (`src/proxy/daemon.ts`) - -Uses macOS `launchd` for: - -- Auto-start at login (`RunAtLoad`) -- Keep alive (`KeepAlive`) -- Port binding (default 18787) - -### 6. Configuration (`src/proxy/config.ts`) - -Stored at `~/.dotmask/config.json`: - -```json -{ - "allowedHosts": [ - "api.anthropic.com", - "api.openai.com" - ] -} -``` - -## Data Flow - -### Request Masking - -``` -1. Claude Code sends request to api.anthropic.com -2. dotmask intercepts via HTTPS_PROXY -3. Parse request body as JSON -4. maskText() scans for secrets: - a. Check against known patterns - b. Check registered (real→fake) mappings - c. Check high-entropy strings - d. Check env-var style assignments -5. For each found secret: - a. Generate fake with makeFake() - b. Store (real, fake) in Keychain - c. Replace real with fake in body -6. Forward modified request to upstream -``` - -### Response Unmasking - -``` -1. Upstream returns response with fake keys -2. Parse response -3. For each known fake key (from cache): - a. Lookup real value in Keychain - b. Replace fake with real -4. Return unmasked response to Claude Code -``` +- JSON and `+json` bodies must parse as an object or array. +- Text, form, GraphQL, and XML bodies are scanned as UTF-8 text. +- Compressed, binary, and unsupported bodies are rejected. +- Content length is recalculated after redaction. -## File Locations +### Masker -| Path | Purpose | -|------|---------| -| `~/.dotmask/config.json` | Allowed hosts configuration | -| `~/.dotmask/ca/ca.pem` | CA certificate (public) | -| `~/.dotmask/ca/ca.key` | CA private key (secret!) | -| `~/.dotmask/maps/*.json` | List of masked (fake) keys | -| `~/.dotmask/proxy.err.log` | Error/debug log | -| `~/.dotmask/proxy.log` | General log | -| `~/.claude/settings.json` | Claude Code settings (injected) | +`src/proxy/masker.ts` recursively visits every string leaf in JSON. Detection combines: -## Dependencies +- Known credential formats such as common AI, cloud, GitHub, Slack, JWT, database, and private-key tokens. +- Secret-looking property and environment-variable names. +- High-entropy token candidates, including encoded tool output. -### Runtime +Fakes preserve length and known structural prefixes. Unknown formats preserve no plaintext prefix. A random process-local HMAC key makes output stable during one daemon run without making it predictable to the provider. -- `node:http` - HTTP server -- `node:https` - HTTPS (not used directly, TLS via node:tls) -- `node:tls` - TLS termination -- `node:crypto` - Key generation, hashing -- `node:child_process` - OpenSSL wrapper +Mappings exist only in the `Map` passed through one request traversal. Dotmask 2 performs no Keychain writes and stores no real-secret mapping on disk. -### Build +## Legacy migration -- `typescript` - TypeScript compilation -- `@types/node` - Node.js types \ No newline at end of file +Version 1 persisted fake-to-real mappings and installed the CA into the login Keychain. Version 2 no longer uses either mechanism. Installation removes legacy CA trust; uninstall additionally deletes identifiable legacy Keychain mappings and `~/.dotmask` after successful daemon, trust, and settings cleanup. diff --git a/README.md b/README.md index a6eb98d..006daab 100644 --- a/README.md +++ b/README.md @@ -1,98 +1,71 @@ # dotmask -mask secrets before they leave your machine. +Best-effort, one-way secret redaction for AI API requests on macOS. -`dotmask` runs a local HTTPS proxy for Claude Code and similar tools. it replaces real secrets with format-preserving fakes on the way out, then restores them locally on the way back. +`dotmask` runs a local HTTPS proxy for Claude Code. Before a supported request reaches an allowed AI provider, it replaces recognized credentials and high-entropy values with keyed, format-preserving fakes. Provider responses are forwarded unchanged: dotmask never inserts a real secret into assistant text or model-generated tool arguments. -## install +## Install ```bash npm install -g @ducnmm/dotmask dotmask install ``` -restart Claude Code after install. +Restart Claude Code after installation. The generated proxy CA is scoped to Claude Code with `NODE_EXTRA_CA_CERTS`; dotmask does not install a system-trusted root. -## use +Version 2 is intentionally incompatible with the old response-unmasking behavior. Tool calls that contain a fake credential must obtain the real credential from a trusted local environment at execution time. -use Claude Code like normal. dotmask runs automatically after install. +## Commands -## supported providers +- `dotmask install [--port ]` — install and start the proxy +- `dotmask allow ` — add an intercepted provider hostname +- `dotmask disallow ` — remove an intercepted hostname +- `dotmask hosts` — list intercepted hostnames +- `dotmask status` — show proxy status +- `dotmask doctor` — diagnose installation issues +- `dotmask uninstall` — remove the daemon, settings, legacy trust, certificates, mappings, and logs -- `api.anthropic.com` - Anthropic (Claude) -- `api.openai.com` - OpenAI (GPT) -- `openrouter.ai`, `api.openrouter.ai` - OpenRouter -- `generativelanguage.googleapis.com` - Google AI (Gemini) -- `api.deepseek.com` - DeepSeek -- `api.groq.com` - Groq -- `api.moonshot.ai` - Moonshot (Kimi) -- `api.together.ai` - Together AI -- `api.fireworks.ai` - Fireworks AI -- `api.cerebras.ai` - Cerebras -- `api.x.ai` - xAI (Grok) -- `api.inference.huggingface.co` - Hugging Face -- `api.minimax.io`, `api.minimax.chat` - MiniMax +## Security behavior -`~/.dotmask/config.json` controls the allowed host list. +For allowlisted hosts, dotmask: -add a custom host with: +1. Terminates the client's TLS connection locally. +2. Rejects compressed, binary, malformed JSON, and unsupported request bodies instead of forwarding them uninspected. +3. Recursively scans every string in JSON requests, plus supported textual request bodies. +4. Replaces known token formats, secret-named properties, environment assignments, and high-entropy values. +5. Forwards provider responses byte-for-byte without restoring secrets. -```bash -dotmask allow chat.trollllm.xyz -``` - -## commands +Fakes are derived with a random, process-local HMAC key. They are stable during one proxy run but cannot be recomputed offline by the provider. Real-to-fake mappings are request-local and real secrets are not persisted by dotmask. -- `dotmask install` - install proxy -- `dotmask install --port 18788` - custom port -- `dotmask allow ` - add allowed host -- `dotmask disallow ` - remove host -- `dotmask hosts` - list allowed hosts -- `dotmask status` - show status -- `dotmask doctor` - diagnose issues -- `dotmask uninstall` - remove everything +## Supported providers -## how it works +The default allowlist includes Anthropic, OpenAI, OpenRouter, Google AI, DeepSeek, Groq, Moonshot, Together, Fireworks, Cerebras, xAI, Hugging Face, and MiniMax endpoints. `~/.dotmask/config.json` controls the exact list. -1. your prompt with API keys goes to Claude Code -2. dotmask intercepts and replaces real keys with fakes -3. fake keys go to the AI API - API thinks its valid -4. response comes back with fake keys -5. dotmask swaps fakes back to real keys -6. Claude Code sees the real response +## Important limitations -your secrets never leave your machine. +Dotmask reduces accidental disclosure; it is not a sandbox, DLP system, or authorization boundary. -## supported secrets +- Provider authentication headers are not masked because the provider requires them. The provider receives those credentials, although the model normally does not. +- Traffic to non-allowlisted hosts is passed through without inspection. +- A model with filesystem or shell-tool permission may read or exfiltrate local data outside the AI request path. Use tool approvals, sandboxing, least-privilege credentials, and network controls. +- Pattern and entropy detection can have false negatives and false positives. Unsupported request formats are blocked for intercepted hosts. +- Fakes deliberately cannot be converted back into real credentials by model output. Generated commands should reference local environment variables or another trusted credential mechanism. -- Anthropic keys: `sk-ant-api03-...` -- OpenAI keys: `sk-proj-...`, `sk-...` -- Stripe: `sk_live_...`, `sk_test_...` -- AWS: `AKIA...` -- Google AI: `AIza...` -- GitHub PATs: `ghp_...`, `gho_...`, `github_pat_...` -- Slack: `xoxb-...`, `xoxp-...` -- JWT tokens -- Database URLs: `postgres://user:pass@...` -- EVM private keys: `0x...` (64 chars) - -## debugging +## Debugging ```bash -# view logs tail -f ~/.dotmask/proxy.err.log - -# run manually with debug DOTMASK_DEBUG=1 node dist/proxy/server.js --port 18787 ``` -## notes +Debug logs contain counts and connection metadata, not request or response bodies. + +## Requirements -- macOS only +- macOS - Node.js 18+ -- `openssl` required -- secrets stored in macOS Keychain +- `openssl` -## license +## License -MIT \ No newline at end of file +MIT diff --git a/SECURITY.md b/SECURITY.md index f07d22b..46b3921 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,66 +1,44 @@ # Security Policy -## Supported Versions +## Supported versions -| Version | Supported | -| ------- | ------------------ | -| 1.0.x | :white_check_mark: | +| Version | Supported | +| --- | --- | +| 2.x | Yes | +| 1.x | No — response unmasking is unsafe | -## Reporting a Vulnerability +## Reporting a vulnerability -If you discover a security vulnerability within dotmask, please create an issue or contact the maintainer directly. +Please report vulnerabilities privately to the maintainer before public disclosure. Include the affected version, reproduction steps, impact, and any suggested mitigation. Avoid including live credentials in reports. -Please include: +## Security design -1. Description of the vulnerability -2. Steps to reproduce -3. Potential impact -4. Suggested fix (if any) +Dotmask 2 is a one-way redaction proxy: -**We ask that you:** -- Give us reasonable time to address the issue before making any public disclosure -- Make a good faith effort to avoid privacy violations, destruction of data, and interruption of service +- Only exact allowlisted hostnames are intercepted. +- Upstream TLS certificate validation remains enabled. +- Supported request bodies are scanned before forwarding. +- Malformed, compressed, binary, and unsupported bodies are blocked for intercepted hosts. +- JSON traversal covers every string leaf, with stricter handling for secret-named properties. +- Pseudonyms use a random process-local HMAC key and do not reveal prefixes for unknown formats. +- Real secret mappings are request-local and are not persisted. +- Provider responses are never unmasked. This prevents model-controlled text and tool calls from acting as a secret-resolution oracle. +- The CA private key and CA directory use restrictive filesystem permissions. +- Claude Code trusts the CA through `NODE_EXTRA_CA_CERTS`; no system-wide trust is installed. -## Security Design +## Threat model and limitations -### MITM Proxy Architecture +Dotmask is intended to reduce accidental disclosure in AI prompts and tool results. It does not protect against: -dotmask uses a local MITM (Man-in-the-Middle) HTTPS proxy to intercept and modify traffic. This requires: +- A local agent that already has permission to read files, environment variables, Keychain entries, or process memory. +- Tool calls that send local data directly to another host. +- Secrets in provider authentication headers; those must reach the provider. +- Traffic to non-allowlisted hosts, which is tunneled without inspection. +- Novel encodings, fragments, or formats that evade both known-pattern and entropy detection. +- A compromised local user account, proxy process, Node runtime, OpenSSL binary, or generated CA private key. -1. **CA Certificate Generation**: A custom CA is generated at `~/.dotmask/ca/` -2. **System Trust**: The CA must be installed and trusted in macOS Keychain -3. **Local Traffic Only**: Only intercepts traffic to configured AI provider domains +Treat dotmask as defense in depth. Keep agent tool approvals enabled, sandbox untrusted repositories, restrict outbound network access, and use short-lived least-privilege credentials. -### Key Security Properties +## Upgrade and cleanup -| Property | Implementation | -|----------|----------------| -| Secrets never leave machine | MITM intercepts before TLS, mask before forwarding | -| Format preservation | Fakes maintain same prefix/length/charset as real | -| Secure storage | Real secrets stored in macOS Keychain | -| Memory safety | Secrets cleared from memory after masking | -| Cache TTL | Keychain lookups cached for 30s only | - -### Known Limitations - -1. **Header masking**: Currently dotmask only masks secrets in request bodies, not HTTP headers -2. **Pattern matching**: Secrets must match known patterns or have high entropy to be masked -3. **Local-only**: CA certificate must be trusted on the same machine - -### Threat Model - -dotmask protects against: - -- ✅ Accidental secret leaks in AI prompts -- ✅ Secrets being stored in AI provider logs -- ✅ Secrets being used for training data - -dotmask does NOT protect against: - -- ❌ Malicious Claude Code prompts that exfiltrate secrets -- ❌ Compromised AI providers -- ❌ Secrets in file system (use other tools like git-secrets, Talisman) - -## Updates - -Security advisories will be posted to the GitHub repository. \ No newline at end of file +Installing version 2 removes a legacy dotmask CA from the login Keychain when present. `dotmask uninstall` fails visibly if daemon or trust cleanup fails, removes legacy mapping entries it can identify, and deletes `~/.dotmask` only after the preceding cleanup succeeds. diff --git a/bun.lock b/bun.lock deleted file mode 100644 index f292fde..0000000 --- a/bun.lock +++ /dev/null @@ -1,73 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 0, - "workspaces": { - "": { - "name": "dotmask", - "dependencies": { - "http-mitm-proxy": "^1.1.0", - }, - "devDependencies": { - "@types/node": "^20.0.0", - "typescript": "^5.4.0", - }, - }, - }, - "packages": { - "@types/node": ["@types/node@20.19.39", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw=="], - - "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], - - "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], - - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - - "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - - "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], - - "http-mitm-proxy": ["http-mitm-proxy@1.1.0", "", { "dependencies": { "async": "^3.2.5", "debug": "^4.3.4", "mkdirp": "^1.0.4", "node-forge": "^1.3.1", "semaphore": "^1.1.0", "uuid": "^9.0.1", "ws": "^8.14.2", "yargs": "^17.7.2" }, "bin": "dist/bin/mitm-proxy.js" }, "sha512-GyjWXiukopLzp6Yg4Dk1M+6Yfp5c/rPtqXDGaRopeJH1bd9F6k2cyJrB98kKmJaMU2P7kA5LLHbtuFc8HcNvrA=="], - - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - - "mkdirp": ["mkdirp@1.0.4", "", { "bin": "bin/cmd.js" }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="], - - "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], - - "semaphore": ["semaphore@1.1.0", "", {}, "sha512-O4OZEaNtkMd/K0i6js9SL+gqy0ZCBMgUvlSqHKi4IBdjhe7wB8pwztUk1BbZ1fmrvpwFrPbHzqd2w5pTcJH6LA=="], - - "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - - "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "uuid": ["uuid@9.0.1", "", { "bin": "dist/bin/uuid" }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], - - "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - - "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], - - "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - - "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], - - "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - } -} diff --git a/package-lock.json b/package-lock.json index 8e5bc92..645d0ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ducnmm/dotmask", - "version": "1.0.7", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ducnmm/dotmask", - "version": "1.0.7", + "version": "2.0.0", "license": "MIT", "bin": { "dm": "bin/dotmask.js", diff --git a/package.json b/package.json index 317dd32..e6ed2cf 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "@ducnmm/dotmask", - "version": "1.0.7", + "version": "2.0.0", "type": "module", - "description": "Transparent AI secret masking for macOS. Runs a local HTTPS proxy that replaces real API keys with format-identical fakes before they reach the AI, then restores them in tool-call responses.", + "description": "One-way AI secret redaction for macOS. Replaces secrets with keyed, format-preserving fakes before requests reach an AI provider.", "bin": { "dotmask": "bin/dotmask.js", "dm": "bin/dotmask.js" diff --git a/src/cli.ts b/src/cli.ts index a5a732a..53f70f9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -31,8 +31,8 @@ ${c.bold("HOW IT WORKS")} Before your prompt leaves your machine: • API keys, tokens, passwords are replaced with fake tokens - • Fake tokens preserve format (same prefix, same length) - • Your app still works — nothing changes locally + • Fake tokens preserve required format without revealing generic prefixes + • Provider responses remain untrusted and are never rewritten to real values ${c.bold("Supported APIs:")} ${c.green("✓")} api.anthropic.com (Claude) @@ -43,7 +43,7 @@ ${c.bold("Supported APIs:")} ${c.bold("QUICK START")} ${c.cyan("1.")} npm install -g @ducnmm/dotmask - ${c.cyan("2.")} dotmask install ${c.dim("# macOS will ask to trust the proxy cert")} + ${c.cyan("2.")} dotmask install ${c.dim("# scopes the proxy CA to Claude Code")} ${c.cyan("3.")} dotmask allow chat.trollllm.xyz ${c.dim("# optional custom host")} ${c.cyan("4.")} Restart Claude Code — done `); diff --git a/src/commands/install.ts b/src/commands/install.ts index 8ce3714..7b8cbdd 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -1,8 +1,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { spawnSync } from "node:child_process"; import { ok, warn, log, error, c, parsePortFlag } from "../utils.js"; -import { CA_CERT_PATH, certExists, isCertTrusted, installCert, uninstallCert } from "../proxy/cert.js"; +import { CA_CERT_PATH, DOTMASK_DIR, certExists, isCertTrusted, uninstallCert } from "../proxy/cert.js"; import { installDaemon, uninstallDaemon, isDaemonLoaded, isDaemonRunning } from "../proxy/daemon.js"; const DEFAULT_PORT = 18787; @@ -273,7 +274,7 @@ export function buildDoctorChecks( ["Proxy daemon loaded", deps.daemonLoaded, "Run dotmask install"], ["Proxy daemon running", deps.daemonRunning, "Run dotmask install or check ~/.dotmask/proxy.err.log"], ["CA cert exists", deps.certExists, "Restart proxy — it generates CA on first run"], - ["CA cert trusted", deps.certTrusted, "Run dotmask install (triggers macOS trust dialog)"], + ["Legacy system CA trust removed", !deps.certTrusted, "Run dotmask install to remove broad legacy trust"], [ "Claude Code settings readable", settings.ok, @@ -322,6 +323,32 @@ function removeProxy(settingsPath: string): RemoveResult { return result; } +function removeLegacyKeychainMappings(): void { + const mapsDir = path.join(DOTMASK_DIR, "maps"); + if (!fs.existsSync(mapsDir)) return; + + const fakeKeys = new Set(); + for (const file of fs.readdirSync(mapsDir).filter((name) => name.endsWith(".json"))) { + try { + const parsed: unknown = JSON.parse(fs.readFileSync(path.join(mapsDir, file), "utf8")); + if (Array.isArray(parsed)) { + for (const value of parsed) if (typeof value === "string") fakeKeys.add(value); + } + } catch { + throw new Error(`cannot parse legacy mapping file: ${path.join(mapsDir, file)}`); + } + } + + for (const fakeKey of fakeKeys) { + const result = spawnSync("security", [ + "delete-generic-password", "-s", "dotmask", "-a", fakeKey, + ], { encoding: "utf8" }); + if (result.status !== 0 && !/could not be found|item not found/i.test(result.stderr)) { + throw new Error(`failed to remove a legacy Keychain mapping: ${result.stderr.trim() || "security command failed"}`); + } + } +} + // ── Commands ────────────────────────────────────────────────────────────────── export function install(args: string[]): number { @@ -347,17 +374,18 @@ export function install(args: string[]): number { waited += 500; } - // 3. Install CA cert + // 3. Remove broad trust left by older dotmask versions. Claude Code trusts + // the proxy CA only through NODE_EXTRA_CA_CERTS below. if (!certExists()) { warn("CA cert not yet generated — run `dotmask doctor` after a moment"); } else if (isCertTrusted()) { - ok("CA cert already trusted"); - } else { - log("\nInstalling CA certificate (you may see a macOS password prompt)..."); - if (installCert()) { - ok("CA cert installed and trusted"); - } else { - warn("CA cert install may have failed — run `dotmask doctor` to verify"); + try { + uninstallCert(); + ok("Removed legacy system-wide CA trust"); + } catch (err) { + error(err instanceof Error ? err.message : String(err)); + try { uninstallDaemon(); } catch { /* preserve the primary error */ } + return 1; } } @@ -408,7 +436,7 @@ export function uninstall(_args: string[]): number { try { uninstallCert(); - ok("CA cert removed from Keychain"); + ok("Legacy CA trust removed from Keychain"); } catch (err) { hadError = true; error(err instanceof Error ? err.message : String(err)); @@ -426,6 +454,17 @@ export function uninstall(_args: string[]): number { error(err instanceof Error ? err.message : String(err)); } + if (!hadError) { + try { + removeLegacyKeychainMappings(); + fs.rmSync(DOTMASK_DIR, { recursive: true, force: true }); + ok("Removed dotmask certificates, mappings, and logs"); + } catch (err) { + hadError = true; + error(err instanceof Error ? err.message : String(err)); + } + } + console.log("\n Restart Claude Code to deactivate.\n"); return hadError ? 1 : 0; } @@ -435,8 +474,8 @@ export function status(): number { const running = isDaemonRunning(); const loaded = isDaemonLoaded(); - const trusted = isCertTrusted(); const certOk = certExists(); + const legacyTrusted = isCertTrusted(); console.log( loaded @@ -449,9 +488,9 @@ export function status(): number { : ` ${c.yellow("○")} CA cert: ${c.yellow("not generated yet")}` ); console.log( - trusted - ? ` ${c.green("●")} CA cert: ${c.green("trusted by macOS")}` - : ` ${c.yellow("○")} CA cert: ${c.yellow("not trusted (run dotmask install)")}` + legacyTrusted + ? ` ${c.red("●")} System CA trust: ${c.red("legacy broad trust remains; run dotmask install")}` + : ` ${c.dim("○")} System CA trust: ${c.dim("not installed; scoped with NODE_EXTRA_CA_CERTS")}`, ); // Check Claude Code settings diff --git a/src/proxy/cert.ts b/src/proxy/cert.ts index 0624682..7571260 100644 --- a/src/proxy/cert.ts +++ b/src/proxy/cert.ts @@ -1,4 +1,4 @@ -import { execFileSync, spawnSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -42,33 +42,23 @@ export function certExists(): boolean { return fs.existsSync(CA_CERT_PATH) && fs.existsSync(CA_KEY_PATH); } -/** - * Install the CA cert into macOS login Keychain and mark it as trusted. - * Will trigger a macOS password/Touch ID prompt. - */ -export function installCert(): boolean { - if (!certExists()) return false; - try { - execFileSync("security", [ - "add-trusted-cert", - "-d", - "-r", "trustRoot", - "-k", LOGIN_KEYCHAIN_PATH, - CA_CERT_PATH, - ], { stdio: "inherit" }); - return true; - } catch { - return false; - } -} - -/** Remove the dotmask CA cert from macOS Keychain. */ +/** Remove legacy dotmask CA trust from macOS Keychain, failing if it remains. */ export function uninstallCert(): void { - try { - execFileSync("security", [ + for (let attempt = 0; attempt < 10; attempt++) { + const existing = spawnSync("security", [ + "find-certificate", "-c", KEYCHAIN_CERT_LABEL, LOGIN_KEYCHAIN_PATH, + ], { encoding: "utf8" }); + if (existing.status !== 0) return; + + const removed = spawnSync("security", [ "delete-certificate", "-c", KEYCHAIN_CERT_LABEL, LOGIN_KEYCHAIN_PATH, - ], { stdio: "pipe" }); - } catch { /* already removed */ } + ], { encoding: "utf8" }); + if (removed.status !== 0) { + throw new Error(`failed to remove legacy dotmask CA trust: ${removed.stderr.trim() || "security command failed"}`); + } + } + + throw new Error("failed to remove all legacy dotmask CA certificates"); } diff --git a/src/proxy/daemon.ts b/src/proxy/daemon.ts index 92d080e..cc63d2a 100644 --- a/src/proxy/daemon.ts +++ b/src/proxy/daemon.ts @@ -78,9 +78,10 @@ export function installDaemon(port: number): void { /** Unload and remove the launchd agent. */ export function uninstallDaemon(): void { if (fs.existsSync(PLIST_PATH)) { - try { + if (isDaemonLoaded()) { execFileSync("launchctl", ["unload", PLIST_PATH], { stdio: "pipe" }); - } catch { /* already unloaded */ } + if (isDaemonLoaded()) throw new Error("dotmask launch agent is still loaded after unload"); + } fs.rmSync(PLIST_PATH, { force: true }); } } diff --git a/src/proxy/http.ts b/src/proxy/http.ts index b6570e6..12d003a 100644 --- a/src/proxy/http.ts +++ b/src/proxy/http.ts @@ -1,4 +1,9 @@ import { decodeChunked, getCompleteChunkedMessageLength } from "./sse.js"; +import { maskJsonPayload, maskText } from "./masker.js"; + +const MAX_HEADER_BYTES = 64 * 1024; +const MAX_BODY_BYTES = 16 * 1024 * 1024; +const MAX_CHUNKED_WIRE_BYTES = MAX_BODY_BYTES + 1024 * 1024; export interface ParsedHttpRequest { requestLine: string; @@ -7,9 +12,50 @@ export interface ParsedHttpRequest { bytesConsumed: number; } +export function sanitizeRequestBody( + rawBody: Buffer, + contentType: string, + contentEncoding: string, +): { body: Buffer; count: number } { + if (rawBody.length === 0) return { body: rawBody, count: 0 }; + if (contentEncoding && contentEncoding.toLowerCase() !== "identity") { + throw new Error(`unsupported request content-encoding: ${contentEncoding}`); + } + + const realToFake = new Map(); + if (/^application\/(?:[a-z0-9.+-]+\+)?json(?:\s*;|$)/i.test(contentType)) { + let parsed: unknown; + try { + parsed = JSON.parse(rawBody.toString("utf8")); + } catch { + throw new Error("invalid JSON request body"); + } + if (typeof parsed !== "object" || parsed === null) { + throw new Error("AI JSON request body must be an object or array"); + } + + const count = maskJsonPayload(parsed, realToFake); + return { body: Buffer.from(JSON.stringify(parsed), "utf8"), count }; + } + + const isTextual = contentType === "" || + /^text\//i.test(contentType) || + /^(?:application\/(?:x-www-form-urlencoded|graphql|xml)|application\/[a-z0-9.+-]+\+xml)(?:\s*;|$)/i.test(contentType); + if (!isTextual || rawBody.includes(0)) { + throw new Error(`unsupported request content-type: ${contentType || "(missing)"}`); + } + + const result = maskText(rawBody.toString("utf8"), realToFake); + return { body: Buffer.from(result.masked, "utf8"), count: result.count }; +} + export function parseCompleteHttpRequest(buffer: Buffer): ParsedHttpRequest | null { const headerEnd = buffer.indexOf("\r\n\r\n"); - if (headerEnd === -1) return null; + if (headerEnd === -1) { + if (buffer.length > MAX_HEADER_BYTES) throw new Error("request headers exceed 64 KiB"); + return null; + } + if (headerEnd > MAX_HEADER_BYTES) throw new Error("request headers exceed 64 KiB"); const headerText = buffer.toString("latin1", 0, headerEnd); const lines = headerText.split("\r\n"); @@ -17,26 +63,46 @@ export function parseCompleteHttpRequest(buffer: Buffer): ParsedHttpRequest | nu const headers: Record = {}; for (const line of lines.slice(1)) { - const idx = line.indexOf(": "); - if (idx >= 0) headers[line.slice(0, idx).toLowerCase()] = line.slice(idx + 2); + const idx = line.indexOf(":"); + if (idx <= 0) throw new Error("malformed request header"); + const name = line.slice(0, idx).trim().toLowerCase(); + if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(name)) throw new Error("invalid request header name"); + if (Object.prototype.hasOwnProperty.call(headers, name)) { + throw new Error(`duplicate request header: ${name}`); + } + headers[name] = line.slice(idx + 1).trimStart(); } const bodyStart = headerEnd + 4; const rawBody = buffer.subarray(bodyStart); - if (headers["transfer-encoding"]?.includes("chunked")) { + if (headers["transfer-encoding"] && headers["content-length"]) { + throw new Error("ambiguous request framing"); + } + if (headers["transfer-encoding"]) { + if (headers["transfer-encoding"].toLowerCase() !== "chunked") { + throw new Error("unsupported transfer-encoding"); + } + if (rawBody.length > MAX_CHUNKED_WIRE_BYTES) throw new Error("request body exceeds 16 MiB"); const chunkedLength = getCompleteChunkedMessageLength(rawBody); if (chunkedLength === null) return null; const chunkedBody = rawBody.subarray(0, chunkedLength); + const decoded = decodeChunked(chunkedBody); + if (decoded.length > MAX_BODY_BYTES) throw new Error("request body exceeds 16 MiB"); return { requestLine, headers, - body: decodeChunked(chunkedBody), + body: decoded, bytesConsumed: bodyStart + chunkedLength, }; } - const bodyExpected = Number.parseInt(headers["content-length"] ?? "0", 10) || 0; + const contentLength = headers["content-length"] ?? "0"; + if (!/^\d+$/.test(contentLength)) throw new Error("invalid content-length"); + const bodyExpected = Number.parseInt(contentLength, 10); + if (!Number.isSafeInteger(bodyExpected) || bodyExpected > MAX_BODY_BYTES) { + throw new Error("request body exceeds 16 MiB"); + } if (rawBody.length < bodyExpected) return null; return { diff --git a/src/proxy/masker.ts b/src/proxy/masker.ts index b1f3fdc..67f66e3 100644 --- a/src/proxy/masker.ts +++ b/src/proxy/masker.ts @@ -1,10 +1,4 @@ import crypto from "node:crypto"; -import { execFileSync, execFile } from "node:child_process"; -import { promisify } from "node:util"; -const execFileAsync = promisify(execFile); -import os from "node:os"; -import path from "node:path"; -import fs from "node:fs"; // ── AI API domains to intercept ─────────────────────────────────────────────── export const AI_DOMAINS = new Set([ @@ -59,6 +53,7 @@ const KNOWN_TOKEN_RE = new RegExp( const SECRET_KEY_RE = /KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|PRIVATE|AUTH/i; +const HIGH_ENTROPY_TOKEN_RE = /(? { - const hash = crypto.createHash("sha256").update(value + `:${idx}`).digest("hex"); - let rng = parseInt(hash.slice(0, 8), 16); - const chars = Array.from({ length: part.length }, () => { - rng = (rng * 1664525 + 1013904223) >>> 0; - return charset[rng % charset.length]; - }).join(""); + const chars = keyedChars(value, `jwt:${idx}`, part.length, charset); return idx === 0 ? "eyJ" + chars.slice(3) : chars; }); return fakeParts.join("."); @@ -147,17 +161,10 @@ export function makeFake(value: string): string { if (!payload) return value; const charset = detectCharset(value, payload); - const hash = crypto.createHash("sha256").update(value).digest("hex"); - const seed = parseInt(hash.slice(0, 8), 16); - const stripped = payload.replace(/=+$/, ""); const padding = "=".repeat(payload.length - stripped.length); - let rng = seed; - const fake = Array.from({ length: stripped.length }, () => { - rng = (rng * 1664525 + 1013904223) >>> 0; - return charset[rng % charset.length]; - }).join("") + padding; + const fake = keyedChars(value, "token", stripped.length, charset) + padding; return prefix + fake; } @@ -175,204 +182,22 @@ function shannonEntropy(s: string): number { function isHighEntropySecret(value: string): boolean { if (value.length < 20 || value.includes("...")) return false; + if (/^0x[0-9a-fA-F]{40}$/.test(value)) return false; // public EVM address const entropy = shannonEntropy(value); if (entropy >= 3.5 && /^[A-Za-z0-9+/\-_=]{20,}$/.test(value)) return true; - if (/^[0-9a-fA-F]{40,}$/.test(value)) return true; + if (/^[0-9a-fA-F]{64,}$/.test(value)) return true; if (value.length >= 40 && /^[A-Za-z0-9+/]{40,}={0,2}$/.test(value)) return true; return false; } -// ── Keychain integration ────────────────────────────────────────────────────── -const KEYCHAIN_SERVICE = "dotmask"; -const MAPS_DIR = path.join(os.homedir(), ".dotmask", "maps"); -const PROMPT_MAP_FILE = path.join(MAPS_DIR, "proxy-discovered.json"); - - -function keychainLookup(fakeKey: string): string | null { - try { - const result = execFileSync("security", [ - "find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", fakeKey, "-w", - ], { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim(); - return result || null; - } catch { - return null; - } -} - -/** Async parallel version — used when building the cache. */ -async function keychainLookupAsync(fakeKey: string): Promise { - try { - const { stdout } = await execFileAsync("security", [ - "find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", fakeKey, "-w", - ], { encoding: "utf8" }); - return stdout.trim() || null; - } catch { - return null; - } -} - -function keychainStore(fakeKey: string, realValue: string): void { - try { - execFileSync("security", [ - "delete-generic-password", "-s", KEYCHAIN_SERVICE, "-a", fakeKey, - ], { stdio: "pipe" }); - } catch { /* ok — might not exist */ } - try { - execFileSync("security", [ - "add-generic-password", "-s", KEYCHAIN_SERVICE, "-a", fakeKey, "-w", realValue, - ], { stdio: "pipe" }); - } catch (err) { - throw new Error( - `failed to store secret mapping in Keychain for ${fakeKey}: ${err instanceof Error ? err.message : String(err)}`, - ); - } -} - -function registerMapping(real: string, fake: string): void { - // Guard: if the "real" value is itself already a known fake key, skip to - // avoid circular mappings (fake → real registered as real → another fake). - if (fakeToRealCache?.has(real)) { - return; - } - // Guard: don't register if real and fake are the same (should not happen, - // but protects against makeFake returning the original value). - if (real === fake) return; - - fs.mkdirSync(MAPS_DIR, { recursive: true }); - let existing: string[] = []; - if (fs.existsSync(PROMPT_MAP_FILE)) { - try { existing = JSON.parse(fs.readFileSync(PROMPT_MAP_FILE, "utf8")); } catch { /**/ } - } - keychainStore(fake, real); - if (!existing.includes(fake)) { - existing.push(fake); - fs.writeFileSync(PROMPT_MAP_FILE, JSON.stringify(existing, null, 2) + "\n", "utf8"); - fs.chmodSync(PROMPT_MAP_FILE, 0o600); - } - if (realToFakeCache) realToFakeCache.set(real, fake); - if (fakeToRealCache) fakeToRealCache.set(fake, real); - if (realToFakeCache || fakeToRealCache) cacheTime = Date.now(); -} - -// ── Keychain map cache (avoid repeated CLI calls per request) ───────────────── -const CACHE_TTL_MS = 30_000; // 30 seconds -let realToFakeCache: Map | null = null; -let fakeToRealCache: Map | null = null; -let cacheTime = 0; - -function isCacheValid(): boolean { - return Date.now() - cacheTime < CACHE_TTL_MS && realToFakeCache !== null && fakeToRealCache !== null; -} - -/** Load real→fake + fake→real maps from Keychain in parallel. */ -async function buildCacheAsync(): Promise { - if (!fs.existsSync(MAPS_DIR)) { - realToFakeCache = new Map(); - fakeToRealCache = new Map(); - cacheTime = Date.now(); - return; - } - - // Collect all unique fake keys across all map files - const allFakeKeys = new Set(); - const jsonFiles = fs.readdirSync(MAPS_DIR).filter(f => f.endsWith(".json")); - for (const file of jsonFiles) { - try { - const fakeKeys: unknown = JSON.parse(fs.readFileSync(path.join(MAPS_DIR, file), "utf8")); - if (Array.isArray(fakeKeys)) fakeKeys.forEach(k => allFakeKeys.add(k as string)); - } catch { /* skip */ } - } - - // Lookup ALL keys in parallel - const keys = [...allFakeKeys]; - const values = await Promise.all(keys.map(k => keychainLookupAsync(k))); - - const r2f = new Map(); - const f2r = new Map(); - for (let i = 0; i < keys.length; i++) { - const fake = keys[i]; - const real = values[i]; - if (real) { - if (!r2f.has(real)) r2f.set(real, fake); - f2r.set(fake, real); - } - } - - realToFakeCache = r2f; - fakeToRealCache = f2r; - cacheTime = Date.now(); -} - -// In-flight cache build promise — prevents multiple parallel rebuilds -let cacheBuilding: Promise | null = null; - -async function ensureCache(): Promise { - if (isCacheValid()) return; - if (!cacheBuilding) { - cacheBuilding = buildCacheAsync().finally(() => { cacheBuilding = null; }); - } - await cacheBuilding; -} - -export async function warmMaskCacheAsync(): Promise { - await ensureCache(); -} - -/** Load real→fake map (async, parallel Keychain lookups). */ -export async function loadRealToFakeMapAsync(): Promise> { - await ensureCache(); - return realToFakeCache ?? new Map(); -} - -/** Load fake→real map (async, parallel Keychain lookups). */ -export async function loadFakeToRealMapAsync(): Promise> { - await ensureCache(); - return fakeToRealCache ?? new Map(); -} - -/** Sync fallback — uses cache if warm, otherwise cold Keychain calls. */ +// Secret mappings are deliberately request-local. Real values are never +// written to disk, Keychain, logs, or a process-wide cache. export function loadRealToFakeMap(): Map { - if (isCacheValid()) return realToFakeCache!; - - const map = new Map(); - const f2r = new Map(); - if (!fs.existsSync(MAPS_DIR)) { - realToFakeCache = map; - fakeToRealCache = f2r; - cacheTime = Date.now(); - return map; - } - - const jsonFiles = fs.readdirSync(MAPS_DIR).filter(f => f.endsWith(".json")); - for (const file of jsonFiles) { - try { - const fakeKeys: unknown = JSON.parse(fs.readFileSync(path.join(MAPS_DIR, file), "utf8")); - if (!Array.isArray(fakeKeys)) continue; - for (const fake of fakeKeys as string[]) { - const real = keychainLookup(fake); - if (real) { - if (!map.has(real)) map.set(real, fake); - f2r.set(fake, real); - } - } - } catch { /* skip corrupt files */ } - } - - realToFakeCache = map; - fakeToRealCache = f2r; - cacheTime = Date.now(); - return map; + return new Map(); } -/** Sync fallback — uses cache if warm. */ -export function loadFakeToRealMap(): Map { - if (isCacheValid() && fakeToRealCache) return fakeToRealCache; - // Fall back: derive from realToFakeMap - const r2f = loadRealToFakeMap(); - const f2r = new Map(); - for (const [real, fake] of r2f) f2r.set(fake, real); - fakeToRealCache = f2r; - return f2r; +function registerMapping(real: string, fake: string, realToFake: Map): void { + if (real !== fake) realToFake.set(real, fake); } // ── Core masking (request: real → fake) ────────────────────────────────────── @@ -390,19 +215,30 @@ export function maskText(text: string, realToFake: Map): { maske } } - // 2. Scan for well-known token patterns not yet in Keychain + // 2. Scan for well-known token patterns. masked = masked.replace(KNOWN_TOKEN_RE, (token) => { if ([...realToFake.values()].includes(token)) return token; // already fake const fake = makeFake(token); if (fake === token) return token; - registerMapping(token, fake); - realToFake.set(token, fake); + registerMapping(token, fake, realToFake); count++; return fake; }); - // 3. Scan env-var assignment lines — but skip values that step 2 already replaced. + // 3. Mask standalone high-entropy values, including encoded tool output. const knownFakes = new Set(realToFake.values()); // fakes registered so far + masked = masked.replace(HIGH_ENTROPY_TOKEN_RE, (token) => { + if ([...knownFakes].some((fake) => fake.includes(token)) || !isHighEntropySecret(token)) return token; + const existing = realToFake.get(token); + const fake = existing ?? makeFake(token); + if (fake === token) return token; + registerMapping(token, fake, realToFake); + knownFakes.add(fake); + count++; + return fake; + }); + + // 4. Scan env-var assignment lines, including lower-entropy passwords. masked = masked.replace( /^([A-Za-z_][A-Za-z0-9_]*)=(.+)$/gm, (line, key: string, rawValue: string) => { @@ -413,13 +249,12 @@ export function maskText(text: string, realToFake: Map): { maske const unquoted = quoted ? value.slice(1, -1) : value; const isSecretKey = SECRET_KEY_RE.test(key); - if ((isSecretKey && unquoted.length >= 16) || isHighEntropySecret(unquoted)) { + if ((isSecretKey && unquoted.length >= 8) || isHighEntropySecret(unquoted)) { if (realToFake.has(unquoted)) return line; // already registered as real if (knownFakes.has(unquoted)) return line; // already a fake — don't double-mask const fake = makeFake(unquoted); if (fake === unquoted) return line; - registerMapping(unquoted, fake); - realToFake.set(unquoted, fake); + registerMapping(unquoted, fake, realToFake); knownFakes.add(fake); count++; if (value.startsWith('"')) return `${key}="${fake}"`; @@ -433,161 +268,48 @@ export function maskText(text: string, realToFake: Map): { maske return { masked, count }; } -// ── Unmasking (response: fake → real) ──────────────────────────────────────── - -/** - * Unmask fake tokens back to real values. - * Used on responses from Anthropic so Claude Code can use the real secrets locally. - */ -export function unmaskText(text: string, fakeToReal: Map): { unmasked: string; count: number } { - let unmasked = text; - let count = 0; - - // Sort by fake key length descending to avoid partial replacements - const sorted = [...fakeToReal.entries()].sort((a, b) => b[0].length - a[0].length); - for (const [fake, real] of sorted) { - if (unmasked.includes(fake)) { - unmasked = unmasked.split(fake).join(real); - count++; - } - } - return { unmasked, count }; -} - /** * Mask secrets in a parsed Anthropic/OpenAI messages array (request). */ export function maskMessages(messages: unknown[], realToFake?: Map): number { - const map = realToFake ?? loadRealToFakeMap(); - let total = 0; - - for (const msg of messages) { - if (typeof msg !== "object" || msg === null) continue; - const m = msg as Record; - - if (typeof m.content === "string") { - const { masked, count } = maskText(m.content, map); - m.content = masked; - total += count; - } else if (Array.isArray(m.content)) { - for (const part of m.content) { - if (typeof part === "object" && part !== null) { - const p = part as Record; - if (typeof p.text === "string") { - const { masked, count } = maskText(p.text, map); - p.text = masked; - total += count; - } - if (typeof p.content === "string") { - const { masked, count } = maskText(p.content, map); - p.content = masked; - total += count; - } else if (Array.isArray(p.content)) { - for (const sub of p.content) { - if (typeof sub !== "object" || sub === null) continue; - const s = sub as Record; - if (typeof s.text === "string") { - const { masked, count } = maskText(s.text, map); - s.text = masked; - total += count; - } - } - } - } - } - } - } - return total; + return maskJsonPayload(messages, realToFake); } /** - * Mask user-provided text fields in known AI request payload shapes. + * Recursively mask every string leaf in an AI JSON request. Secret-looking + * property names receive stricter treatment so ordinary passwords are covered. */ export function maskJsonPayload(body: unknown, realToFake?: Map): number { const map = realToFake ?? loadRealToFakeMap(); let total = 0; - function maskStringField(record: Record, key: string): void { - if (typeof record[key] !== "string") return; - const { masked, count } = maskText(record[key] as string, map); - record[key] = masked; - total += count; + function maskString(value: string, keyHint: string): string { + const result = maskText(value, map); + total += result.count; + if (result.count > 0 || !SECRET_KEY_RE.test(keyHint) || value.length < 8) { + return result.masked; + } + + const fake = map.get(value) ?? makeFake(value); + if (fake === value) return value; + registerMapping(value, fake, map); + total++; + return fake; } - function walk(node: unknown): void { + function walk(node: unknown, keyHint = ""): unknown { + if (typeof node === "string") return maskString(node, keyHint); if (Array.isArray(node)) { - for (const item of node) walk(item); - return; + for (let i = 0; i < node.length; i++) node[i] = walk(node[i], keyHint); + return node; } - if (typeof node !== "object" || node === null) return; + if (typeof node !== "object" || node === null) return node; const record = node as Record; - maskStringField(record, "text"); - maskStringField(record, "input_text"); - maskStringField(record, "instructions"); - - if (typeof record.content === "string") { - maskStringField(record, "content"); - } else if (Array.isArray(record.content)) { - walk(record.content); - } - - if (typeof record.input === "string") { - maskStringField(record, "input"); - } else if (Array.isArray(record.input)) { - walk(record.input); - } - - if (typeof record.system === "string") { - maskStringField(record, "system"); - } else if (Array.isArray(record.system) || (typeof record.system === "object" && record.system !== null)) { - walk(record.system); - } - - for (const key of ["messages", "contents", "parts"]) { - if (Array.isArray(record[key])) { - walk(record[key]); - } - } - - if (typeof record.system_instruction === "object" && record.system_instruction !== null) { - walk(record.system_instruction); - } + for (const [key, value] of Object.entries(record)) record[key] = walk(value, key); + return record; } walk(body); return total; } - -/** - * Finds the safe length of string that can be flushed to the client without - * accidentally truncating a fake token prefix that could be reconstructed in the next chunk. - */ -export function findSafeFlushLength(text: string, fakeKeys: string[]): number { - let minSafeLength = text.length; - for (const fk of fakeKeys) { - const maxSuffixLen = Math.min(text.length, fk.length); - for (let i = 0; i < maxSuffixLen; i++) { - const suffixLen = maxSuffixLen - i; - const suffixStartIndex = text.length - suffixLen; - if (suffixStartIndex >= minSafeLength) continue; - - let match = true; - for (let j = 0; j < suffixLen; j++) { - if (text[suffixStartIndex + j] !== fk[j]) { - match = false; - break; - } - } - if (match) { - // Only hold back for a STRICT PREFIX of fk (key may continue in next chunk). - // If suffixLen === fk.length the complete key is present → safe to flush. - if (suffixLen < fk.length) { - minSafeLength = Math.min(minSafeLength, suffixStartIndex); - } - break; - } - } - } - return minSafeLength; -} diff --git a/src/proxy/server.ts b/src/proxy/server.ts index 49a541b..278bd96 100644 --- a/src/proxy/server.ts +++ b/src/proxy/server.ts @@ -12,15 +12,12 @@ import net from "node:net"; import tls from "node:tls"; import crypto from "node:crypto"; import fs from "node:fs"; -import { StringDecoder } from "node:string_decoder"; import path from "node:path"; import os from "node:os"; import { spawnSync } from "node:child_process"; -import { unmaskText, loadFakeToRealMapAsync, loadRealToFakeMapAsync, findSafeFlushLength, warmMaskCacheAsync, maskJsonPayload } from "./masker.js"; import { CA_DIR, CA_CERT_PATH, CA_KEY_PATH, DOTMASK_DIR } from "./cert.js"; import { loadAllowedHosts, normalizeHost } from "./config.js"; -import { IncrementalChunkedBodyParser, SseEventBuffer, encodeChunkedPayload, TERMINAL_CHUNK } from "./sse.js"; -import { parseCompleteHttpRequest } from "./http.js"; +import { parseCompleteHttpRequest, sanitizeRequestBody } from "./http.js"; import { parsePortFlag } from "../utils.js"; // ── Config ─────────────────────────────────────────────────────────────── @@ -132,23 +129,6 @@ function ensureCA(): void { // ── Request body masking ────────────────────────────────────────────────────── -async function maskRequestBody(rawBody: Buffer, host: string): Promise { - let body: unknown; - try { - body = JSON.parse(rawBody.toString("utf8")); - } catch { - return rawBody; // non-JSON body — pass through - } - - const realToFake = await loadRealToFakeMapAsync(); - const totalCount = maskJsonPayload(body, realToFake); - if (totalCount > 0) { - dbg(`masked ${totalCount} secret(s) in request to ${host}`); - return Buffer.from(JSON.stringify(body), "utf8"); - } - return rawBody; -} - function writeProxyErrorResponse(clientTls: tls.TLSSocket, message: string): void { if (clientTls.destroyed) return; const body = JSON.stringify({ error: message }); @@ -254,12 +234,21 @@ function handleConnect( tlsServer.on("data", (chunk: Buffer) => { pending = Buffer.concat([pending, chunk]); - while (true) { - const parsed = parseCompleteHttpRequest(pending); - if (!parsed) return; + try { + while (true) { + const parsed = parseCompleteHttpRequest(pending); + if (!parsed) return; - pending = pending.subarray(parsed.bytesConsumed); - startForward(parsed.requestLine, parsed.headers, parsed.body); + pending = pending.subarray(parsed.bytesConsumed); + startForward(parsed.requestLine, parsed.headers, parsed.body); + } + } catch (err) { + connectionFailed = true; + pending = Buffer.alloc(0); + writeProxyErrorResponse( + tlsServer, + err instanceof Error ? `dotmask blocked malformed request: ${err.message}` : "dotmask blocked malformed request", + ); } }); } @@ -276,12 +265,10 @@ async function forwardRequest( const urlPath = requestLine.split(" ")[1] ?? "/"; const contentType = reqHeaders["content-type"] ?? ""; - let finalBody = bodyBuf; - if (contentType.includes("application/json") && method !== "GET") { - finalBody = await maskRequestBody(bodyBuf, hostname); - } - - const fakeToReal = await loadFakeToRealMapAsync(); + const contentEncoding = reqHeaders["content-encoding"] ?? ""; + const sanitized = sanitizeRequestBody(bodyBuf, contentType, contentEncoding); + if (sanitized.count > 0) dbg(`masked ${sanitized.count} secret(s) in request to ${hostname}`); + const finalBody = sanitized.body; const outHeaders: Record = { ...reqHeaders }; outHeaders["content-length"] = String(finalBody.length); @@ -291,17 +278,6 @@ async function forwardRequest( await new Promise((resolve, reject) => { const outSocket = tls.connect({ host: hostname, port, servername: hostname }); let settled = false; - let responseHeadersDone = false; - let textBuffer = ""; - const decoder = new StringDecoder("utf8"); - const fakeKeys = Array.from(fakeToReal.keys()); - let isSse = false; - let isChunked = false; - let isCompressed = false; - let rawBuf: Buffer = Buffer.alloc(0); - let handleEndCalled = false; - let sseChunkParser: IncrementalChunkedBodyParser | null = null; - let sseEventBuffer: SseEventBuffer | null = null; function finish(err?: Error): void { if (settled) return; @@ -318,118 +294,6 @@ async function forwardRequest( if (!clientTls.destroyed) clientTls.write(chunk); } - function sendSseEvents(events: string[]): void { - for (const event of events) { - if (event.length === 0) continue; - if (isChunked) { - sendToClient(encodeChunkedPayload(event)); - } else { - sendToClient(Buffer.from(event, "utf8")); - } - } - if (events.length > 0) dbg(`[SSE] flushed ${events.length} event(s)`); - } - - function processSseBody(rawBody: Buffer): void { - if (rawBody.length === 0 || handleEndCalled) return; - if (!sseEventBuffer) sseEventBuffer = new SseEventBuffer(fakeToReal); - - if (!isChunked) { - sendSseEvents(sseEventBuffer.push(rawBody)); - return; - } - - if (!sseChunkParser) sseChunkParser = new IncrementalChunkedBodyParser(); - const parsed = sseChunkParser.push(rawBody); - for (const payload of parsed.payloads) { - sendSseEvents(sseEventBuffer.push(payload)); - } - if (parsed.terminal) handleEnd(); - } - - function handleData(chunk: Buffer): void { - if (!responseHeadersDone) { - rawBuf = Buffer.concat([rawBuf, chunk]); - textBuffer += decoder.write(chunk); - - const headerEnd = textBuffer.indexOf("\r\n\r\n"); - if (headerEnd === -1) return; - - responseHeadersDone = true; - const headers = textBuffer.slice(0, headerEnd + 4); - const ctHeaderMatch = headers.toLowerCase().match(/content-type:\s*([^\r\n]+)/); - const ctValue = ctHeaderMatch?.[1]?.trim() ?? "(none)"; - const ceHeaderMatch = headers.toLowerCase().match(/content-encoding:\s*([^\r\n]+)/); - const ceValue = ceHeaderMatch?.[1]?.trim() ?? "none"; - isSse = /content-type\s*:\s*text\/event-stream/i.test(headers); - isChunked = /transfer-encoding\s*:\s*chunked/i.test(headers); - isCompressed = !isSse && /content-encoding\s*:\s*(gzip|br|zstd|deflate)/i.test(headers); - dbg(`response isSse=${isSse}, isChunked=${isChunked}, isCompressed=${isCompressed}, content-type=${ctValue}, content-encoding=${ceValue} for ${hostname}`); - sendToClient(Buffer.from(headers, "utf8")); - - if (isCompressed) { - const rawBody = rawBuf.slice(headerEnd + 4); - if (rawBody.length > 0) sendToClient(rawBody); - rawBuf = Buffer.alloc(0); - textBuffer = ""; - return; - } - if (isSse) { - sseEventBuffer = new SseEventBuffer(fakeToReal); - if (isChunked) sseChunkParser = new IncrementalChunkedBodyParser(); - const rawBody = rawBuf.slice(headerEnd + 4); - rawBuf = Buffer.alloc(0); - textBuffer = ""; - processSseBody(rawBody); - return; - } - - textBuffer = textBuffer.slice(headerEnd + 4); - rawBuf = Buffer.alloc(0); - } else { - if (isCompressed) { - sendToClient(chunk); - return; - } - if (isSse) { - processSseBody(chunk); - return; - } - textBuffer += decoder.write(chunk); - } - - if (textBuffer.length === 0) return; - - const safeLen = findSafeFlushLength(textBuffer, fakeKeys); - if (safeLen > 0) { - const toProcess = textBuffer.slice(0, safeLen); - textBuffer = textBuffer.slice(safeLen); - const { unmasked, count } = unmaskText(toProcess, fakeToReal); - if (count > 0) dbg(`[non-SSE] unmasked ${count} secret(s)`); - sendToClient(Buffer.from(unmasked, "utf8")); - } - } - - function handleEnd(): void { - if (handleEndCalled) return; - handleEndCalled = true; - const decoderTail = decoder.end(); - if (!isSse && !isCompressed) textBuffer += decoderTail; - dbg(`[handleEnd] isSse=${isSse} isCompressed=${isCompressed} textBuffer=${textBuffer.length}B`); - - if (isSse) { - if (!sseEventBuffer) sseEventBuffer = new SseEventBuffer(fakeToReal); - sendSseEvents(sseEventBuffer.finish()); - if (isChunked) sendToClient(TERMINAL_CHUNK); - } else if (!isCompressed && textBuffer.length > 0) { - const { unmasked, count } = unmaskText(textBuffer, fakeToReal); - if (count > 0) dbg(`[handleEnd] unmasked ${count} remaining secret(s)`); - sendToClient(Buffer.from(unmasked, "utf8")); - } - - finish(); - } - outSocket.on("error", (e) => { dbg("upstream error:", e.message); finish(e instanceof Error ? e : new Error(String(e))); @@ -444,14 +308,10 @@ async function forwardRequest( if (finalBody.length > 0) outSocket.write(finalBody); }); - if (fakeToReal.size === 0) { - outSocket.on("data", sendToClient); - outSocket.on("end", () => finish()); - return; - } - - outSocket.on("data", handleData); - outSocket.on("end", handleEnd); + // Provider responses are untrusted input. Forward them byte-for-byte and + // never materialize a real secret in model-controlled text or tool calls. + outSocket.on("data", sendToClient); + outSocket.on("end", () => finish()); }); } @@ -459,7 +319,6 @@ async function forwardRequest( async function main(): Promise { ensureCA(); - void warmMaskCacheAsync().catch((e) => dbg("mask cache warmup failed:", e)); dbg("MITM allowlist:", Array.from(ALLOWED_HOSTS)); const server = http.createServer((req, res) => { diff --git a/src/proxy/sse.ts b/src/proxy/sse.ts index d569875..3405f25 100644 --- a/src/proxy/sse.ts +++ b/src/proxy/sse.ts @@ -1,17 +1,3 @@ -import { StringDecoder } from "node:string_decoder"; -import { unmaskText } from "./masker.js"; - -interface FragmentMatch { - start: number; - end: number; - decoded: string; -} - -interface EventFieldMatch { - eventIndex: number; - decoded: string; -} - export interface ChunkedParseResult { payloads: Buffer[]; terminal: boolean; @@ -44,25 +30,20 @@ export class IncrementalChunkedBodyParser { const sizeLine = this.buffer.toString("ascii", 0, lineEnd).trim(); const sizeHex = sizeLine.split(";", 1)[0]; - if (!/^[0-9a-fA-F]+$/.test(sizeHex)) { - throw new Error(`invalid chunk size line: ${sizeLine}`); - } + if (!/^[0-9a-fA-F]+$/.test(sizeHex)) throw new Error(`invalid chunk size line: ${sizeLine}`); - const len = parseInt(sizeHex, 16); + const len = Number.parseInt(sizeHex, 16); const payloadStart = lineEnd + 2; - if (len === 0) { let trailerOffset = payloadStart; while (true) { const trailerEnd = this.buffer.indexOf("\r\n", trailerOffset); if (trailerEnd === -1) return { payloads, terminal: false }; - if (trailerEnd === trailerOffset) { this.buffer = this.buffer.subarray(trailerEnd + 2); this.ended = true; return { payloads, terminal: true }; } - trailerOffset = trailerEnd + 2; } } @@ -81,116 +62,39 @@ export class IncrementalChunkedBodyParser { } } -export class SseEventBuffer { - private decoder = new StringDecoder("utf8"); - private pendingText = ""; - private events: string[] = []; - - constructor(private readonly fakeToReal: Map) {} - - push(payload: Buffer | string): string[] { - const text = Buffer.isBuffer(payload) ? this.decoder.write(payload) : payload; - return this.pushText(text); - } - - finish(): string[] { - const tail = this.decoder.end(); - if (tail.length > 0) this.pendingText += normalizeSseText(tail); - if (this.pendingText.length > 0) { - this.events.push(this.pendingText); - this.pendingText = ""; - } - return this.drainEvents(true); - } - - private pushText(text: string): string[] { - const parsed = extractCompleteSseEvents(this.pendingText + text); - this.pendingText = parsed.remaining; - this.events.push(...parsed.events); - return this.drainEvents(false); - } - - private drainEvents(force: boolean): string[] { - if (this.events.length === 0) return []; - - const flushCount = force - ? this.events.length - : getSafeSseEventFlushCount(this.events, this.fakeToReal); - if (flushCount <= 0) return []; - - const originalCount = this.events.length; - const unmasked = unmaskSseFragments(this.events.join(""), this.fakeToReal); - const processedEvents = splitSseEventText(unmasked); - - if (processedEvents.length !== originalCount) { - if (flushCount === originalCount) { - this.events = []; - return unmasked.length > 0 ? [unmasked] : []; - } - return []; - } - - const flushed = processedEvents.slice(0, flushCount); - this.events = processedEvents.slice(flushCount); - return flushed; - } -} - -function normalizeSseText(text: string): string { - return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); -} - -function extractCompleteSseEvents(text: string): { events: string[]; remaining: string } { - const normalized = normalizeSseText(text); - const events: string[] = []; - let start = 0; - - while (true) { - const sep = normalized.indexOf("\n\n", start); - if (sep === -1) break; - - const event = normalized.slice(start, sep + 2); - if (event.trim().length > 0) events.push(event); - start = sep + 2; - } - - return { events, remaining: normalized.slice(start) }; -} - -function splitSseEventText(text: string): string[] { - const parsed = extractCompleteSseEvents(text); - if (parsed.remaining.length > 0) parsed.events.push(parsed.remaining); - return parsed.events; -} - export function decodeChunked(buffer: Buffer): Buffer { const chunks: Buffer[] = []; let offset = 0; while (offset < buffer.length) { const nextCrLf = buffer.indexOf("\r\n", offset); if (nextCrLf === -1) break; - const hex = buffer.toString("utf8", offset, nextCrLf).trim(); - const len = parseInt(hex, 16); - if (isNaN(len)) break; + const sizeLine = buffer.toString("ascii", offset, nextCrLf).trim(); + const sizeHex = sizeLine.split(";", 1)[0]; + if (!/^[0-9a-fA-F]+$/.test(sizeHex)) throw new Error("invalid chunk size"); + const len = Number.parseInt(sizeHex, 16); if (len === 0) break; - chunks.push(buffer.subarray(nextCrLf + 2, nextCrLf + 2 + len)); - offset = nextCrLf + 2 + len + 2; + const payloadStart = nextCrLf + 2; + const payloadEnd = payloadStart + len; + if (buffer.length < payloadEnd + 2) throw new Error("truncated chunked body"); + if (buffer[payloadEnd] !== 0x0d || buffer[payloadEnd + 1] !== 0x0a) { + throw new Error("invalid chunk payload terminator"); + } + chunks.push(buffer.subarray(payloadStart, payloadEnd)); + offset = payloadEnd + 2; } return Buffer.concat(chunks); } export function getCompleteChunkedMessageLength(buffer: Buffer): number | null { let offset = 0; - while (offset < buffer.length) { const lineEnd = buffer.indexOf("\r\n", offset); if (lineEnd === -1) return null; const sizeLine = buffer.toString("ascii", offset, lineEnd).trim(); const sizeHex = sizeLine.split(";", 1)[0]; - if (!/^[0-9a-fA-F]+$/.test(sizeHex)) return null; - - const len = parseInt(sizeHex, 16); + if (!/^[0-9a-fA-F]+$/.test(sizeHex)) throw new Error("invalid chunk size"); + const len = Number.parseInt(sizeHex, 16); offset = lineEnd + 2; if (len === 0) { @@ -205,10 +109,11 @@ export function getCompleteChunkedMessageLength(buffer: Buffer): number | null { const chunkEnd = offset + len; if (buffer.length < chunkEnd + 2) return null; - if (buffer[chunkEnd] !== 0x0d || buffer[chunkEnd + 1] !== 0x0a) return null; + if (buffer[chunkEnd] !== 0x0d || buffer[chunkEnd + 1] !== 0x0a) { + throw new Error("invalid chunk payload terminator"); + } offset = chunkEnd + 2; } - return null; } @@ -217,7 +122,7 @@ export function stripChunkSizeLines(text: string): { stripped: string; hasTermin const stripped = text .replace(/\r\n/g, "\n") .replace(/^([0-9a-fA-F]+)(?:;[^\n]*)?\n/gm, (_, hex) => { - if (parseInt(hex, 16) === 0) hasTerminal = true; + if (Number.parseInt(hex, 16) === 0) hasTerminal = true; return ""; }); return { stripped, hasTerminal }; @@ -227,172 +132,8 @@ export function rawBytesHaveTerminal(buf: Buffer): boolean { for (let i = 0; i <= buf.length - 5; i++) { const startsAtLineBoundary = i === 0 || (buf[i - 2] === 0x0d && buf[i - 1] === 0x0a); if (!startsAtLineBoundary) continue; - if (buf[i] === 0x30 && buf[i+1] === 0x0D && buf[i+2] === 0x0A && - buf[i+3] === 0x0D && buf[i+4] === 0x0A) { - return true; - } - } - return false; -} - -function collectFieldMatches(src: string, fieldName: string): FragmentMatch[] { - const frags: FragmentMatch[] = []; - const prefix = `"${fieldName}":"`; - const re = new RegExp(`"${fieldName}":"((?:[^"\\\\]|\\\\.)*)"`, "g"); - let m: RegExpExecArray | null; - while ((m = re.exec(src)) !== null) { - const valueStart = m.index + prefix.length; - let decoded: string; - try { decoded = JSON.parse('"' + m[1] + '"'); } catch { continue; } - frags.push({ start: valueStart, end: valueStart + m[1].length, decoded }); - } - return frags; -} - -function replaceMatches(src: string, replacements: Array<{ start: number; end: number; value: string }>): string { - let out = src; - for (let i = replacements.length - 1; i >= 0; i--) { - out = out.slice(0, replacements[i].start) + replacements[i].value + out.slice(replacements[i].end); + if (buf[i] === 0x30 && buf[i + 1] === 0x0d && buf[i + 2] === 0x0a && + buf[i + 3] === 0x0d && buf[i + 4] === 0x0a) return true; } - return out; -} - -function longestFakePrefixSuffix(text: string, fake: string): number { - const maxLen = Math.min(text.length, fake.length - 1); - for (let len = maxLen; len > 0; len--) { - if (text.endsWith(fake.slice(0, len))) { - return len; - } - } - return 0; -} - -function collectEventFieldMatches(events: string[], fieldName: string): EventFieldMatch[] { - const matches: EventFieldMatch[] = []; - for (let eventIndex = 0; eventIndex < events.length; eventIndex++) { - for (const match of collectFieldMatches(events[eventIndex], fieldName)) { - matches.push({ eventIndex, decoded: match.decoded }); - } - } - return matches; -} - -function candidateCompletes(values: EventFieldMatch[], start: number, fake: string): boolean { - let progress = longestFakePrefixSuffix(values[start].decoded, fake); - if (progress === 0) return true; - - let sequence = fake.slice(0, progress); - for (let idx = start + 1; idx < values.length; idx++) { - const nextSequence = sequence + values[idx].decoded; - if (nextSequence.startsWith(fake)) return true; - - const nextProgress = longestFakePrefixSuffix(nextSequence, fake); - if (nextProgress > progress) { - sequence = nextSequence; - progress = nextProgress; - } - } - return false; } - -export function getSafeSseEventFlushCount(events: string[], fakeToReal: Map): number { - if (events.length === 0 || fakeToReal.size === 0) return events.length; - - let earliestHold: number | null = null; - const fakeKeys = [...fakeToReal.keys()].sort((a, b) => b.length - a.length); - - for (const fieldName of ["partial_json", "text", "content"]) { - const values = collectEventFieldMatches(events, fieldName); - if (values.length === 0) continue; - - for (const fake of fakeKeys) { - for (let start = 0; start < values.length; start++) { - const progress = longestFakePrefixSuffix(values[start].decoded, fake); - if (progress === 0) continue; - if (candidateCompletes(values, start, fake)) continue; - - const eventIndex = values[start].eventIndex; - earliestHold = earliestHold === null ? eventIndex : Math.min(earliestHold, eventIndex); - } - } - } - - return earliestHold === null ? events.length : earliestHold; -} - -export function reassembleField(src: string, fieldName: string, fakeToReal: Map): string { - const matches = collectFieldMatches(src, fieldName); - if (matches.length === 0 || fakeToReal.size === 0) return src; - - const values = matches.map((match) => match.decoded); - const replacements: Array<{ start: number; end: number; value: string }> = []; - - for (const [fake, real] of [...fakeToReal.entries()].sort((a, b) => b[0].length - a[0].length)) { - const used = new Set(); - - for (let start = 0; start < values.length; start++) { - if (used.has(start)) continue; - - let sequence = values[start]; - let progress = longestFakePrefixSuffix(sequence, fake); - let hit = sequence.includes(fake); - if (!hit && progress === 0) continue; - - const indices = [start]; - for (let idx = start + 1; idx < values.length && !hit; idx++) { - if (used.has(idx)) continue; - - const nextSequence = sequence + values[idx]; - if (nextSequence.includes(fake)) { - sequence = nextSequence; - indices.push(idx); - hit = true; - break; - } - - const nextProgress = longestFakePrefixSuffix(nextSequence, fake); - if (nextProgress > progress) { - sequence = nextSequence; - progress = nextProgress; - indices.push(idx); - } - } - - if (!hit) continue; - - const replaced = sequence.split(fake).join(real); - let offset = 0; - for (const idx of indices) { - const updated = replaced.slice(offset, offset + values[idx].length); - offset += values[idx].length; - if (updated === values[idx]) continue; - values[idx] = updated; - replacements.push({ - start: matches[idx].start, - end: matches[idx].end, - value: JSON.stringify(updated).slice(1, -1), - }); - } - - for (const idx of indices) { - used.add(idx); - } - } - } - - if (replacements.length === 0) return src; - return replaceMatches(src, replacements); -} - -export function unmaskSseFragments(text: string, fakeToReal: Map): string { - let result = text; - const { unmasked: simple, count: simpleCount } = unmaskText(text, fakeToReal); - if (simpleCount > 0) { result = simple; } - - result = reassembleField(result, "partial_json", fakeToReal); - result = reassembleField(result, "text", fakeToReal); - result = reassembleField(result, "content", fakeToReal); - - return result; -} diff --git a/src/utils.ts b/src/utils.ts index 3408e69..a516f12 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -17,7 +17,7 @@ export const dim = (msg: string) => console.log(c.dim(` ${msg}`)); export function requireMacOS(): void { if (process.platform !== "darwin") { - throw new Error("macOS Keychain required. dotmask only works on macOS."); + throw new Error("macOS launchd integration required. dotmask only works on macOS."); } } diff --git a/test/http.test.js b/test/http.test.js index ef6069f..098b9ff 100644 --- a/test/http.test.js +++ b/test/http.test.js @@ -1,6 +1,6 @@ import { describe, test } from "node:test"; import assert from "node:assert/strict"; -import { parseCompleteHttpRequest } from "../dist/proxy/http.js"; +import { parseCompleteHttpRequest, sanitizeRequestBody } from "../dist/proxy/http.js"; describe("parseCompleteHttpRequest", () => { test("parses content-length request with UTF-8 body without corruption", () => { @@ -70,4 +70,58 @@ describe("parseCompleteHttpRequest", () => { assert.equal(parsed.bytesConsumed, first.length); assert.equal(combined.subarray(parsed.bytesConsumed).toString("latin1"), second.toString("latin1")); }); + + test("rejects ambiguous content-length plus transfer-encoding framing", () => { + const request = Buffer.from( + "POST / HTTP/1.1\r\nHost: example.com\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n", + "latin1", + ); + assert.throws(() => parseCompleteHttpRequest(request), /ambiguous request framing/); + }); + + test("rejects duplicate headers", () => { + const request = Buffer.from( + "GET / HTTP/1.1\r\nHost: example.com\r\nHost: attacker.example\r\n\r\n", + "latin1", + ); + assert.throws(() => parseCompleteHttpRequest(request), /duplicate request header/); + }); +}); + +describe("sanitizeRequestBody", () => { + test("recursively masks secrets in arbitrary JSON properties", () => { + const real = "correct horse battery staple"; + const input = Buffer.from(JSON.stringify({ messages: [{ metadata: { password: real } }] })); + const result = sanitizeRequestBody(input, "application/json", ""); + const parsed = JSON.parse(result.body.toString("utf8")); + + assert.equal(result.count, 1); + assert.notEqual(parsed.messages[0].metadata.password, real); + assert.equal(parsed.messages[0].metadata.password.length, real.length); + }); + + test("blocks malformed JSON instead of forwarding it", () => { + assert.throws( + () => sanitizeRequestBody(Buffer.from("{invalid"), "application/json", ""), + /invalid JSON request body/, + ); + }); + + test("blocks compressed and binary request bodies", () => { + assert.throws( + () => sanitizeRequestBody(Buffer.from("payload"), "application/json", "gzip"), + /unsupported request content-encoding/, + ); + assert.throws( + () => sanitizeRequestBody(Buffer.from([0, 1, 2]), "application/octet-stream", ""), + /unsupported request content-type/, + ); + }); + + test("masks secrets in textual non-JSON bodies", () => { + const real = "sk-proj-abcdefghijklmnopqrstuvwxyz12345"; + const result = sanitizeRequestBody(Buffer.from(`token=${real}`), "text/plain", ""); + assert.equal(result.count, 1); + assert.ok(!result.body.toString("utf8").includes(real)); + }); }); diff --git a/test/install.test.js b/test/install.test.js index 5a539c8..d8fc00e 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -151,7 +151,7 @@ describe("install settings helpers", () => { daemonLoaded: true, daemonRunning: true, certExists: true, - certTrusted: true, + certTrusted: false, }, ); @@ -179,7 +179,7 @@ describe("install settings helpers", () => { daemonLoaded: true, daemonRunning: true, certExists: true, - certTrusted: true, + certTrusted: false, }, ); @@ -202,7 +202,7 @@ describe("install settings helpers", () => { daemonLoaded: true, daemonRunning: true, certExists: true, - certTrusted: true, + certTrusted: false, }, ); diff --git a/test/masker.test.js b/test/masker.test.js index 76c277a..1627eb1 100644 --- a/test/masker.test.js +++ b/test/masker.test.js @@ -4,7 +4,7 @@ */ import { test, describe } from "node:test"; import assert from "node:assert/strict"; -import { makeFake, maskText, unmaskText, isAiDomain, findSafeFlushLength, maskMessages, maskJsonPayload } from "../dist/proxy/masker.js"; +import { makeFake, maskText, isAiDomain, maskMessages, maskJsonPayload } from "../dist/proxy/masker.js"; // ── makeFake ────────────────────────────────────────────────────────────────── @@ -60,6 +60,13 @@ describe("makeFake", () => { assert.equal(makeFake("abc"), "abc"); assert.equal(makeFake(""), ""); }); + + test("does not preserve a plaintext prefix for unknown formats", () => { + const real = "correct-horse-battery-staple"; + const fake = makeFake(real); + assert.equal(fake.length, real.length); + assert.notEqual(fake.slice(0, 5), real.slice(0, 5)); + }); }); // ── maskText ────────────────────────────────────────────────────────────────── @@ -100,6 +107,13 @@ describe("maskText (no keychain — empty map)", () => { assert.ok(!masked.includes("abcdefghijklmnopqrstuvwxyz12345")); }); + test("masks standalone high-entropy encoded output", () => { + const encoded = "c3VwZXJzZWNyZXRwYXNzd29yZDEyMzQ1Njc4OTA="; + const { masked, count } = maskText(`tool output: ${encoded}`, new Map()); + assert.equal(count, 1); + assert.ok(!masked.includes(encoded)); + }); + test("does not mask short values", () => { const { masked, count } = maskText("SECRET=short", new Map()); assert.equal(count, 0); @@ -117,49 +131,6 @@ describe("maskText (no keychain — empty map)", () => { }); }); -// ── unmaskText ──────────────────────────────────────────────────────────────── - -describe("unmaskText", () => { - test("reverses what maskText did", () => { - const real = "sk-or-v1-abcdefghijklmnopqrstuvwxyz12345"; - const fake = makeFake(real); - const fakeToReal = new Map([[fake, real]]); - - const { unmasked, count } = unmaskText(`here is the key: ${fake} end`, fakeToReal); - assert.equal(count, 1); - assert.ok(unmasked.includes(real)); - assert.ok(!unmasked.includes(fake)); - }); - - test("handles multiple tokens", () => { - const r1 = "sk-or-v1-aaaabbbbccccddddeeeeffffgggg1234"; - const r2 = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcde12345"; - const f1 = makeFake(r1); - const f2 = makeFake(r2); - const map = new Map([[f1, r1], [f2, r2]]); - - const { unmasked, count } = unmaskText(`key1=${f1} key2=${f2}`, map); - assert.equal(count, 2); - assert.ok(unmasked.includes(r1)); - assert.ok(unmasked.includes(r2)); - }); - - test("no-op when map is empty", () => { - const text = "no secrets here"; - const { unmasked, count } = unmaskText(text, new Map()); - assert.equal(count, 0); - assert.equal(unmasked, text); - }); - - test("round-trip: mask then unmask = original", () => { - const real = "sk-or-v1-abcdefghijklmnopqrstuvwxyz12345"; - const { masked } = maskText(`my key is ${real}`, new Map()); - const fake = makeFake(real); - const { unmasked } = unmaskText(masked, new Map([[fake, real]])); - assert.ok(unmasked.includes(real)); - }); -}); - // ── isAiDomain ──────────────────────────────────────────────────────────────── describe("isAiDomain", () => { @@ -172,72 +143,6 @@ describe("isAiDomain", () => { test("does NOT match npm registry", () => assert.ok(!isAiDomain("registry.npmjs.org"))); }); -// ── findSafeFlushLength (streaming split recovery) ──────────────────────────── - -describe("findSafeFlushLength", () => { - const fakeKeys = [ - "sk-or-v1-abcdefghijklmnopqrstuvwxyz12345", - "sk-ant-api03-verylongrealkey1234567890abcdef", - ]; - - test("flushes everything if no prefix match is found", () => { - const text = "hello world! this is just a normal response chunk."; - const len = findSafeFlushLength(text, fakeKeys); - assert.equal(len, text.length, "should be totally safe to flush"); - }); - - test("stops before a partial fake key matching at the end", () => { - // "sk-or-v1-a" is an exact prefix of the first fakekey - const partial = "sk-or-v1-abcde"; - const text = `some response text before the key ${partial}`; - const len = findSafeFlushLength(text, fakeKeys); - - // safe length should be right before the split token - assert.equal(text.substring(0, len), "some response text before the key "); - assert.equal(text.substring(len), partial); - }); - - test("stops before a very short partial match at the very end", () => { - // just "sk" which matches "sk-or-v1..." and "sk-ant..." - const text = `just some text followed by sk`; - const len = findSafeFlushLength(text, fakeKeys); - - // safe length should chop off 'sk' - assert.equal(text.substring(0, len), "just some text followed by "); - }); - - test("passes through occurrences that do NOT match the prefix", () => { - // 'sk-something-else' doesn't match the specific fakeKeys precisely beyond 'sk-' - // Wait, 'sk-something' has 'sk-' as prefix which DOES match "sk-or-v1" up to 3 chars - // But what if it's "sk-nomatch"? It will match "sk-" suffix but nothing else. - // The findSafeFlushLength strictly finds if a suffix of the text matches a prefix of fakeKey. - // "followed by sk-" -> "sk-" matches the first 3 chars. So it will hold back "sk-". - const text = "followed by sk-"; - const len = findSafeFlushLength(text, fakeKeys); - assert.equal(text.substring(len), "sk-"); - }); - - test("if exact key is fully present in the buffer, it is flushed (unmask handles replacement)", () => { - // When the full key is present, findSafeFlushLength returns text.length — the whole - // buffer is safe to flush because unmaskText will do the replacement afterwards. - const text = `full key here sk-or-v1-abcdefghijklmnopqrstuvwxyz12345`; - const len = findSafeFlushLength(text, fakeKeys); - assert.equal(len, text.length, "full fake key present → flush everything, unmask will handle it"); - }); - - test("works with multiple fake keys without issue", () => { - const multiKeys = [ - "sk-or-v1-abc", - "sk-ant-api-xyz" - ]; - const text1 = "response sk-or"; - assert.equal(findSafeFlushLength(text1, multiKeys), "response ".length); - - const text2 = "response sk-ant-a"; - assert.equal(findSafeFlushLength(text2, multiKeys), "response ".length); - }); -}); - // ── New token types (AWS, Stripe, JWT, Sui) ─────────────────────────────────── describe("makeFake — new token types", () => { @@ -346,21 +251,6 @@ describe("maskText — new token types", () => { assert.ok(masked.includes("suiprivkey"), "suiprivkey prefix must be preserved"); }); - test("round-trip: AWS key mask then unmask = original", () => { - const real = "AKIAIOSFODNN7EXAMPLE"; - const { masked } = maskText(`key=${real}`, new Map()); - const fake = makeFake(real); - const { unmasked } = unmaskText(masked, new Map([[fake, real]])); - assert.ok(unmasked.includes(real), "real AWS key must be restored after unmask"); - }); - - test("round-trip: JWT mask then unmask = original", () => { - const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyMTIzIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; - const { masked } = maskText(`token=${jwt}`, new Map()); - const fake = makeFake(jwt); - const { unmasked } = unmaskText(masked, new Map([[fake, jwt]])); - assert.ok(unmasked.includes(jwt), "real JWT must be restored after unmask"); - }); }); // ── maskMessages ───────────────────────────────────────────────────────────── @@ -529,6 +419,15 @@ describe("maskJsonPayload", () => { const payload = { foo: "bar", nested: [{ nope: 1 }] }; assert.equal(maskJsonPayload(payload, new Map()), 0); }); + + test("masks generic secrets under arbitrary nested property names", () => { + const real = "correct horse battery staple"; + const payload = { tool_result: { arbitrary: { database_password: real } } }; + const count = maskJsonPayload(payload, new Map()); + + assert.equal(count, 1); + assert.notEqual(payload.tool_result.arbitrary.database_password, real); + }); }); // ── maskText — additional edge cases ───────────────────────────────────────── @@ -612,71 +511,3 @@ describe("maskText — edge cases", () => { assert.ok(!masked.includes(token)); }); }); - -// ── unmaskText — additional edge cases ─────────────────────────────────────── - -describe("unmaskText — edge cases", () => { - test("longest fake key matched first (substring safety)", () => { - const shortReal = "sk-proj-shortshortshortshort1234"; - const longReal = "sk-proj-shortshortshortshort1234-extended"; - const shortFake = makeFake(shortReal); - const longFake = makeFake(longReal); - const map = new Map([[shortFake, shortReal], [longFake, longReal]]); - const { unmasked } = unmaskText(`val=${longFake}`, map); - assert.ok(unmasked.includes(longReal)); - }); - - test("fake key at start of string", () => { - const real = "sk-proj-abcdefghijklmnopqrstuvwxyz12345"; - const fake = makeFake(real); - const { unmasked, count } = unmaskText(`${fake} trailing`, new Map([[fake, real]])); - assert.equal(count, 1); - assert.ok(unmasked.startsWith(real)); - }); - - test("fake key at end of string", () => { - const real = "sk-proj-abcdefghijklmnopqrstuvwxyz12345"; - const fake = makeFake(real); - const { unmasked, count } = unmaskText(`leading ${fake}`, new Map([[fake, real]])); - assert.equal(count, 1); - assert.ok(unmasked.endsWith(real)); - }); - - test("fake key appearing multiple times — count is 1 (one key type)", () => { - const real = "sk-proj-abcdefghijklmnopqrstuvwxyz12345"; - const fake = makeFake(real); - const { unmasked, count } = unmaskText(`${fake} and ${fake}`, new Map([[fake, real]])); - assert.equal(count, 1, "count tracks distinct key types, not occurrences"); - const occurrences = unmasked.split(real).length - 1; - assert.equal(occurrences, 2); - }); -}); - -// ── findSafeFlushLength — additional edge cases ────────────────────────────── - -describe("findSafeFlushLength — edge cases", () => { - test("key in the middle (not at end) — flush all", () => { - const fakeKeys = ["sk-or-v1-abcdefghijklmnopqrstuvwxyz12345"]; - const text = `prefix ${fakeKeys[0]} suffix`; - const len = findSafeFlushLength(text, fakeKeys); - assert.equal(len, text.length); - }); - - test("empty fakeKeys — flush all", () => { - const text = "anything at all sk-or-v1-abc"; - const len = findSafeFlushLength(text, []); - assert.equal(len, text.length); - }); - - test("empty text — 0", () => { - const len = findSafeFlushLength("", ["sk-or-v1-abc"]); - assert.equal(len, 0); - }); - - test("text shorter than shortest partial match", () => { - const fakeKeys = ["sk-or-v1-abcdefghijklmnopqrstuvwxyz12345"]; - const text = "s"; - const len = findSafeFlushLength(text, fakeKeys); - assert.equal(len, 0, "'s' matches first char of fake key — hold back"); - }); -}); diff --git a/test/sse.test.js b/test/sse.test.js index 489bc85..11a7ce5 100644 --- a/test/sse.test.js +++ b/test/sse.test.js @@ -1,618 +1,57 @@ -/** - * Unit tests for SSE handling — extracted pure functions from server.ts - * Run: node --test test/sse.test.js - */ -import { test, describe } from "node:test"; +import { describe, test } from "node:test"; import assert from "node:assert/strict"; import { decodeChunked, - stripChunkSizeLines, - rawBytesHaveTerminal, - reassembleField, - unmaskSseFragments, + encodeChunkedPayload, getCompleteChunkedMessageLength, IncrementalChunkedBodyParser, - SseEventBuffer, - encodeChunkedPayload, + rawBytesHaveTerminal, + stripChunkSizeLines, TERMINAL_CHUNK, } from "../dist/proxy/sse.js"; -import { makeFake, unmaskText } from "../dist/proxy/masker.js"; - -// ── Helper: build a fake→real map from a real key ──────────────────────────── -function buildMap(realKey) { - const fake = makeFake(realKey); - return { fake, real: realKey, fakeToReal: new Map([[fake, realKey]]) }; -} - -function fieldValues(text, fieldName) { - const re = new RegExp(`"${fieldName}":"((?:[^"\\\\]|\\\\.)*)"`, "g"); - return [...text.matchAll(re)].map((m) => JSON.parse('"' + m[1] + '"')); -} - -// ── stripChunkSizeLines ────────────────────────────────────────────────────── - -describe("stripChunkSizeLines", () => { - test("removes hex chunk-size line before SSE event", () => { - const input = '1e\r\ndata: {"type":"ping"}\r\n\r\n'; - const { stripped } = stripChunkSizeLines(input); - assert.ok(stripped.includes('data: {"type":"ping"}')); - assert.ok(!stripped.match(/^1e$/m)); - }); - - test("handles multiple chunk-size lines in sequence", () => { - const input = - '1a\r\ndata: {"type":"event1"}\r\n\r\n' + - '1b\r\ndata: {"type":"event2"}\r\n\r\n'; - const { stripped } = stripChunkSizeLines(input); - assert.ok(stripped.includes("event1")); - assert.ok(stripped.includes("event2")); - assert.ok(!stripped.match(/^1[ab]$/m)); - }); - - test("strips chunk extension (semicolon suffix)", () => { - const input = 'a3;ext=foo\r\ndata: {"ok":true}\r\n\r\n'; - const { stripped } = stripChunkSizeLines(input); - assert.ok(stripped.includes('data: {"ok":true}')); - assert.ok(!stripped.includes("ext=foo")); - }); - - test("terminal zero-chunk sets hasTerminal", () => { - const input = '0\r\n\r\n'; - const { hasTerminal } = stripChunkSizeLines(input); - assert.ok(hasTerminal); - }); - - test("non-terminal input does not set hasTerminal", () => { - const input = '1a\r\ndata: {"type":"ping"}\r\n\r\n'; - const { hasTerminal } = stripChunkSizeLines(input); - assert.ok(!hasTerminal); - }); - - test("data lines containing hex-like content are preserved", () => { - // "deadbeef" is valid hex but appears inside a data: line, not as a standalone chunk-size - const input = 'data: {"partial_json":"deadbeef"}\n\n'; - const { stripped } = stripChunkSizeLines(input); - assert.ok(stripped.includes("deadbeef")); - }); - - test("LF-only input passes through unchanged", () => { - const input = 'data: {"type":"ping"}\n\n'; - const { stripped } = stripChunkSizeLines(input); - assert.equal(stripped, input); - }); - - test("mixed CRLF and LF", () => { - const input = '1a\r\ndata: event1\r\n\ndata: event2\n\n'; - const { stripped } = stripChunkSizeLines(input); - assert.ok(stripped.includes("data: event1")); - assert.ok(stripped.includes("data: event2")); - assert.ok(!stripped.match(/^1a$/m)); - }); -}); - -// ── rawBytesHaveTerminal ───────────────────────────────────────────────────── - -describe("rawBytesHaveTerminal", () => { - test("detects 0\\r\\n\\r\\n at end of buffer", () => { - const buf = Buffer.from("some data\r\n0\r\n\r\n"); - assert.ok(rawBytesHaveTerminal(buf)); - }); - - test("detects 0\\r\\n\\r\\n in standalone packet", () => { - const buf = Buffer.from("0\r\n\r\n"); - assert.ok(rawBytesHaveTerminal(buf)); - }); - - test("does NOT match hex 10 (decimal 16)", () => { - const buf = Buffer.from("10\r\n\r\n"); - assert.ok(!rawBytesHaveTerminal(buf)); - }); - - test("empty buffer returns false", () => { - assert.ok(!rawBytesHaveTerminal(Buffer.alloc(0))); - }); - - test("buffer shorter than 5 bytes returns false", () => { - assert.ok(!rawBytesHaveTerminal(Buffer.from("0\r\n\r"))); - assert.ok(!rawBytesHaveTerminal(Buffer.from("ab"))); - }); -}); - -// ── reassembleField ────────────────────────────────────────────────────────── - -describe("reassembleField", () => { - test("reassembles partial_json split across two fragments", () => { - const real = "sk-proj-abcdefghijklmnopqrstuvwxyz12345"; - const { fake, fakeToReal } = buildMap(real); - const half = Math.floor(fake.length / 2); - const part1 = fake.slice(0, half); - const part2 = fake.slice(half); - - const input = - `data: {"delta":{"type":"input_json_delta","partial_json":"${part1}"}}\n\n` + - `data: {"delta":{"type":"input_json_delta","partial_json":"${part2}"}}\n\n`; - - const result = reassembleField(input, "partial_json", fakeToReal); - assert.ok(!result.includes(fake), "fake key should be replaced"); - // Verify the real key parts are distributed across fragments - const reExtract = /"partial_json":"([^"]*)"/g; - let m; - let reconstructed = ""; - while ((m = reExtract.exec(result)) !== null) { - reconstructed += m[1]; - } - assert.equal(reconstructed, real); - }); - - test("reassembles key split into three fragments", () => { - const real = "sk-or-v1-abcdefghijklmnopqrstuvwxyz12345"; - const { fake, fakeToReal } = buildMap(real); - const p1 = fake.slice(0, 10); - const p2 = fake.slice(10, 25); - const p3 = fake.slice(25); - - const input = - `{"partial_json":"${p1}"}` + "\n" + - `{"partial_json":"${p2}"}` + "\n" + - `{"partial_json":"${p3}"}`; - - const result = reassembleField(input, "partial_json", fakeToReal); - assert.ok(!result.includes(fake)); - }); - - test("full key in single fragment — no change needed", () => { - const real = "sk-proj-abcdefghijklmnopqrstuvwxyz12345"; - const { fake, fakeToReal } = buildMap(real); - - const input = `{"partial_json":"${fake}"}`; - const result = reassembleField(input, "partial_json", fakeToReal); - assert.ok(result.includes(real)); - }); - - test("no fake key present — passthrough unchanged", () => { - const fakeToReal = new Map([["sk-proj-fakefakefakefakefake1234", "sk-proj-realrealrealrealreal1234"]]); - const input = `{"partial_json":"hello world"}`; - const result = reassembleField(input, "partial_json", fakeToReal); - assert.equal(result, input); - }); - - test("empty partial_json fragment handled gracefully", () => { - const real = "sk-proj-abcdefghijklmnopqrstuvwxyz12345"; - const { fake, fakeToReal } = buildMap(real); - - const input = - `{"partial_json":""}` + "\n" + - `{"partial_json":"${fake}"}`; - - const result = reassembleField(input, "partial_json", fakeToReal); - assert.ok(result.includes(real)); - assert.ok(result.includes('"partial_json":""')); - }); - - test("JSON-escaped characters in fragment round-trip correctly", () => { - const real = "sk-proj-abcdefghijklmnopqrstuvwxyz12345"; - const { fake, fakeToReal } = buildMap(real); - const half = Math.floor(fake.length / 2); - - // Fragment with escaped newline before partial key - const input = - `{"partial_json":"line1\\nline2${fake.slice(0, half)}"}` + "\n" + - `{"partial_json":"${fake.slice(half)}"}`; - - const result = reassembleField(input, "partial_json", fakeToReal); - assert.ok(result.includes("line1\\nline2"), "escaped newline preserved"); - assert.ok(!result.includes(fake)); - }); - - test("fragment straddles key/non-key boundary", () => { - const real = "sk-proj-abcdefghijklmnopqrstuvwxyz12345"; - const { fake, fakeToReal } = buildMap(real); - const half = Math.floor(fake.length / 2); - - const input = - `{"partial_json":"prefix ${fake.slice(0, half)}"}` + "\n" + - `{"partial_json":"${fake.slice(half)} suffix"}`; - - const result = reassembleField(input, "partial_json", fakeToReal); - assert.ok(result.includes("prefix ")); - assert.ok(result.includes(" suffix")); - assert.ok(!result.includes(fake)); - }); - - test("works with text field name", () => { - const real = "sk-proj-abcdefghijklmnopqrstuvwxyz12345"; - const { fake, fakeToReal } = buildMap(real); - const half = Math.floor(fake.length / 2); - - const input = - `{"text":"${fake.slice(0, half)}"}` + "\n" + - `{"text":"${fake.slice(half)}"}`; - - const result = reassembleField(input, "text", fakeToReal); - assert.ok(!result.includes(fake)); - }); - - test("works with content field name (OpenAI format)", () => { - const real = "sk-proj-abcdefghijklmnopqrstuvwxyz12345"; - const { fake, fakeToReal } = buildMap(real); - const half = Math.floor(fake.length / 2); - - const input = - `{"content":"${fake.slice(0, half)}"}` + "\n" + - `{"content":"${fake.slice(half)}"}`; - - const result = reassembleField(input, "content", fakeToReal); - assert.ok(!result.includes(fake)); - }); - - test("two interleaved fake keys (two tool calls)", () => { - const real1 = "sk-proj-aaaabbbbccccddddeeeeffffgggg1234"; - const real2 = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcde12345"; - const { fake: fake1, fakeToReal: m1 } = buildMap(real1); - const { fake: fake2 } = buildMap(real2); - const fakeToReal = new Map([...m1, [fake2, real2]]); - - // Interleaved: key1 part1, key2 part1, key1 part2, key2 part2 - const half1 = Math.floor(fake1.length / 2); - const half2 = Math.floor(fake2.length / 2); - const input = - `{"partial_json":"${fake1.slice(0, half1)}"}` + "\n" + - `{"partial_json":"${fake2.slice(0, half2)}"}` + "\n" + - `{"partial_json":"${fake1.slice(half1)}"}` + "\n" + - `{"partial_json":"${fake2.slice(half2)}"}`; - const result = reassembleField(input, "partial_json", fakeToReal); - const allPartials = [...result.matchAll(/"partial_json":"([^"]*)"/g)].map(m => m[1]); - assert.equal(allPartials[0] + allPartials[2], real1); - assert.equal(allPartials[1] + allPartials[3], real2); - }); - - test("interleaved keys with surrounding text are reassembled independently", () => { - const real1 = "sk-proj-aaaabbbbccccddddeeeeffffgggg1234"; - const real2 = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcde12345"; - const { fake: fake1, fakeToReal: m1 } = buildMap(real1); - const { fake: fake2 } = buildMap(real2); - const fakeToReal = new Map([...m1, [fake2, real2]]); - const half1 = Math.floor(fake1.length / 2); - const half2 = Math.floor(fake2.length / 2); - - const input = - `{"text":"alpha ${fake1.slice(0, half1)}"}` + "\n" + - `{"text":"beta ${fake2.slice(0, half2)}"}` + "\n" + - `{"text":"${fake1.slice(half1)} omega"}` + "\n" + - `{"text":"${fake2.slice(half2)} delta"}`; - - const result = reassembleField(input, "text", fakeToReal); - const allTexts = [...result.matchAll(/"text":"([^"]*)"/g)].map(m => JSON.parse('"' + m[1] + '"')); - assert.equal(allTexts[0] + allTexts[2], `alpha ${real1} omega`); - assert.equal(allTexts[1] + allTexts[3], `beta ${real2} delta`); - }); -}); - -// ── unmaskSseFragments ─────────────────────────────────────────────────────── - -describe("unmaskSseFragments", () => { - test("simple unmask when full key is in one SSE event", () => { - const real = "sk-proj-abcdefghijklmnopqrstuvwxyz12345"; - const { fake, fakeToReal } = buildMap(real); - - const input = `data: {"delta":{"text":"your key is ${fake}"}}\n\n`; - const result = unmaskSseFragments(input, fakeToReal); - assert.ok(result.includes(real)); - assert.ok(!result.includes(fake)); - }); - - test("fragment unmask via partial_json across events", () => { - const real = "sk-proj-abcdefghijklmnopqrstuvwxyz12345"; - const { fake, fakeToReal } = buildMap(real); - const half = Math.floor(fake.length / 2); - - const input = - `data: {"delta":{"partial_json":"${fake.slice(0, half)}"}}\n\n` + - `data: {"delta":{"partial_json":"${fake.slice(half)}"}}\n\n`; - - const result = unmaskSseFragments(input, fakeToReal); - const allPartials = [...result.matchAll(/"partial_json":"([^"]*)"/g)].map(m => m[1]); - const reconstructed = allPartials.join(""); - assert.equal(reconstructed, real); - }); - - test("text_delta fragments unmasked", () => { - const real = "sk-proj-abcdefghijklmnopqrstuvwxyz12345"; - const { fake, fakeToReal } = buildMap(real); - const third = Math.floor(fake.length / 3); - - const input = - `data: {"delta":{"text":"${fake.slice(0, third)}"}}\n\n` + - `data: {"delta":{"text":"${fake.slice(third, third * 2)}"}}\n\n` + - `data: {"delta":{"text":"${fake.slice(third * 2)}"}}\n\n`; - - const result = unmaskSseFragments(input, fakeToReal); - const allTexts = [...result.matchAll(/"text":"([^"]*)"/g)].map(m => m[1]); - const reconstructed = allTexts.join(""); - assert.equal(reconstructed, real); - }); - - test("content field fragments unmasked (OpenAI format)", () => { - const real = "sk-proj-abcdefghijklmnopqrstuvwxyz12345"; - const { fake, fakeToReal } = buildMap(real); - const half = Math.floor(fake.length / 2); - - const input = - `data: {"choices":[{"delta":{"content":"${fake.slice(0, half)}"}}]}\n\n` + - `data: {"choices":[{"delta":{"content":"${fake.slice(half)}"}}]}\n\n`; - - const result = unmaskSseFragments(input, fakeToReal); - const allContent = [...result.matchAll(/"content":"([^"]*)"/g)].map(m => m[1]); - const reconstructed = allContent.join(""); - assert.equal(reconstructed, real); - }); - - test("no fake keys — passthrough unchanged", () => { - const fakeToReal = new Map([["sk-proj-nonexistentfakefakefake1234", "sk-proj-realvalue"]]); - const input = 'data: {"delta":{"text":"hello world"}}\n\n'; - const result = unmaskSseFragments(input, fakeToReal); - assert.equal(result, input); - }); - - test("simple unmask does not cause double-replace in reassemble", () => { - const real = "sk-proj-abcdefghijklmnopqrstuvwxyz12345"; - const { fake, fakeToReal } = buildMap(real); - - // Full key in a single text field — simple unmask handles it, - // then reassembleField should not try to unmask the now-real value again - const input = `data: {"delta":{"text":"${fake}"}}\n\n`; - const result = unmaskSseFragments(input, fakeToReal); - assert.ok(result.includes(real)); - // Make sure it wasn't double-processed (real key should appear exactly once) - const count = result.split(real).length - 1; - assert.equal(count, 1, "real key should appear exactly once"); - }); - - test("empty fakeToReal map — passthrough", () => { - const input = 'data: {"delta":{"text":"some text"}}\n\n'; - const result = unmaskSseFragments(input, new Map()); - assert.equal(result, input); - }); - - test("unicode characters adjacent to fake key", () => { - const real = "sk-proj-abcdefghijklmnopqrstuvwxyz12345"; - const { fake, fakeToReal } = buildMap(real); - const half = Math.floor(fake.length / 2); - - const input = - `data: {"delta":{"text":"\\u4f60\\u597d${fake.slice(0, half)}"}}\n\n` + - `data: {"delta":{"text":"${fake.slice(half)}\\u4e16\\u754c"}}\n\n`; - - const result = unmaskSseFragments(input, fakeToReal); - assert.ok(!result.includes(fake)); - }); - - test("interleaved partial_json fragments are unmasked without mixing streams", () => { - const real1 = "sk-proj-aaaabbbbccccddddeeeeffffgggg1234"; - const real2 = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcde12345"; - const { fake: fake1, fakeToReal: m1 } = buildMap(real1); - const { fake: fake2 } = buildMap(real2); - const fakeToReal = new Map([...m1, [fake2, real2]]); - const half1 = Math.floor(fake1.length / 2); - const half2 = Math.floor(fake2.length / 2); - - const input = - `data: {"delta":{"partial_json":"${fake1.slice(0, half1)}"}}\n\n` + - `data: {"delta":{"partial_json":"${fake2.slice(0, half2)}"}}\n\n` + - `data: {"delta":{"partial_json":"${fake1.slice(half1)}"}}\n\n` + - `data: {"delta":{"partial_json":"${fake2.slice(half2)}"}}\n\n`; - - const result = unmaskSseFragments(input, fakeToReal); - const allPartials = [...result.matchAll(/"partial_json":"([^"]*)"/g)].map(m => m[1]); - assert.equal(allPartials[0] + allPartials[2], real1); - assert.equal(allPartials[1] + allPartials[3], real2); - }); -}); - -// ── Incremental SSE streaming ──────────────────────────────────────────────── - -describe("SseEventBuffer", () => { - test("first normal SSE event flushes before terminal chunk", () => { - const { fakeToReal } = buildMap("sk-proj-abcdefghijklmnopqrstuvwxyz12345"); - const parser = new IncrementalChunkedBodyParser(); - const events = new SseEventBuffer(fakeToReal); - const event = 'data: {"delta":{"text":"hello"}}\n\n'; - - const parsed = parser.push(encodeChunkedPayload(event)); - const flushed = parsed.payloads.flatMap((payload) => events.push(payload)); - - assert.equal(parsed.terminal, false); - assert.deepEqual(flushed, [event]); - }); - - test("full fake token inside one event is unmasked and flushed", () => { - const real = "sk-proj-abcdefghijklmnopqrstuvwxyz12345"; - const { fake, fakeToReal } = buildMap(real); - const events = new SseEventBuffer(fakeToReal); - const event = `data: {"delta":{"text":"your key is ${fake}"}}\n\n`; - - const flushed = events.push(Buffer.from(event)); - - assert.equal(flushed.length, 1); - assert.ok(flushed[0].includes(real)); - assert.ok(!flushed[0].includes(fake)); - }); - - test("fake token split across two partial_json events is held until reconstructable", () => { - const real = "sk-proj-abcdefghijklmnopqrstuvwxyz12345"; - const { fake, fakeToReal } = buildMap(real); - const half = Math.floor(fake.length / 2); - const events = new SseEventBuffer(fakeToReal); - - const first = `data: {"delta":{"partial_json":"${fake.slice(0, half)}"}}\n\n`; - const second = `data: {"delta":{"partial_json":"${fake.slice(half)}"}}\n\n`; - - assert.deepEqual(events.push(Buffer.from(first)), []); - const flushed = events.push(Buffer.from(second)); - const joined = flushed.join(""); - - assert.equal(flushed.length, 2); - assert.ok(!joined.includes(fake)); - assert.equal(fieldValues(joined, "partial_json").join(""), real); - }); - - test("safe events before a split token flush while the split event is held", () => { - const real = "sk-proj-abcdefghijklmnopqrstuvwxyz12345"; - const { fake, fakeToReal } = buildMap(real); - const half = Math.floor(fake.length / 2); - const events = new SseEventBuffer(fakeToReal); - const normal = 'data: {"delta":{"text":"ready"}}\n\n'; - const first = `data: {"delta":{"partial_json":"${fake.slice(0, half)}"}}\n\n`; - const second = `data: {"delta":{"partial_json":"${fake.slice(half)}"}}\n\n`; - - assert.deepEqual(events.push(Buffer.from(normal + first)), [normal]); - const flushed = events.push(Buffer.from(second)); - const joined = flushed.join(""); - - assert.equal(flushed.length, 2); - assert.equal(fieldValues(joined, "partial_json").join(""), real); - }); - - test("fake token split across three partial_json events is held only until complete", () => { - const real = "sk-or-v1-abcdefghijklmnopqrstuvwxyz12345"; - const { fake, fakeToReal } = buildMap(real); - const events = new SseEventBuffer(fakeToReal); - const firstCut = 10; - const secondCut = 25; - - const first = `data: {"delta":{"partial_json":"${fake.slice(0, firstCut)}"}}\n\n`; - const second = `data: {"delta":{"partial_json":"${fake.slice(firstCut, secondCut)}"}}\n\n`; - const third = `data: {"delta":{"partial_json":"${fake.slice(secondCut)}"}}\n\n`; - - assert.deepEqual(events.push(Buffer.from(first)), []); - assert.deepEqual(events.push(Buffer.from(second)), []); - const flushed = events.push(Buffer.from(third)); - const joined = flushed.join(""); - - assert.equal(flushed.length, 3); - assert.ok(!joined.includes(fake)); - assert.equal(fieldValues(joined, "partial_json").join(""), real); - }); - - test("Anthropic-style text fragments unmask incrementally", () => { - const real = "sk-proj-abcdefghijklmnopqrstuvwxyz12345"; - const { fake, fakeToReal } = buildMap(real); - const half = Math.floor(fake.length / 2); - const events = new SseEventBuffer(fakeToReal); - - assert.deepEqual(events.push(Buffer.from(`data: {"delta":{"text":"${fake.slice(0, half)}"}}\n\n`)), []); - const flushed = events.push(Buffer.from(`data: {"delta":{"text":"${fake.slice(half)}"}}\n\n`)); - const joined = flushed.join(""); - - assert.equal(fieldValues(joined, "text").join(""), real); - assert.ok(!joined.includes(fake)); - }); - - test("OpenAI-style content fragments unmask incrementally", () => { - const real = "sk-proj-abcdefghijklmnopqrstuvwxyz12345"; - const { fake, fakeToReal } = buildMap(real); - const half = Math.floor(fake.length / 2); - const events = new SseEventBuffer(fakeToReal); - - assert.deepEqual(events.push(Buffer.from(`data: {"choices":[{"delta":{"content":"${fake.slice(0, half)}"}}]}\n\n`)), []); - const flushed = events.push(Buffer.from(`data: {"choices":[{"delta":{"content":"${fake.slice(half)}"}}]}\n\n`)); - const joined = flushed.join(""); - - assert.equal(fieldValues(joined, "content").join(""), real); - assert.ok(!joined.includes(fake)); - }); - - test("incomplete event remains buffered until delimiter arrives", () => { - const { fakeToReal } = buildMap("sk-proj-abcdefghijklmnopqrstuvwxyz12345"); - const events = new SseEventBuffer(fakeToReal); - const partial = 'data: {"delta":{"text":"hello"}}\n'; - - assert.deepEqual(events.push(Buffer.from(partial)), []); - assert.deepEqual(events.push(Buffer.from("\n")), [partial + "\n"]); - }); - - test("chunked SSE output uses valid chunk sizes and terminal chunk", () => { - const first = 'data: {"delta":{"text":"hello"}}\n\n'; - const second = "data: [DONE]\n\n"; - const output = Buffer.concat([ - encodeChunkedPayload(first), - encodeChunkedPayload(second), +describe("chunked HTTP helpers", () => { + test("encodes and decodes multiple payloads", () => { + const wire = Buffer.concat([ + encodeChunkedPayload("hello"), + encodeChunkedPayload(" world"), TERMINAL_CHUNK, ]); - - assert.equal(getCompleteChunkedMessageLength(output), output.length); - assert.equal(decodeChunked(output).toString("utf8"), first + second); - }); -}); - -// ── decodeChunked ──────────────────────────────────────────────────────────── - -describe("decodeChunked", () => { - test("single chunk decode", () => { - const input = Buffer.from("5\r\nhello\r\n0\r\n\r\n"); - const result = decodeChunked(input); - assert.equal(result.toString(), "hello"); - }); - - test("multiple chunks concatenated", () => { - const input = Buffer.from("5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n"); - const result = decodeChunked(input); - assert.equal(result.toString(), "hello world"); - }); - - test("chunk extension tolerated (parseInt stops at semicolon)", () => { - const input = Buffer.from("5;ext=foo\r\nhello\r\n0\r\n\r\n"); - const result = decodeChunked(input); - assert.equal(result.toString(), "hello"); + assert.equal(decodeChunked(wire).toString("utf8"), "hello world"); + assert.equal(getCompleteChunkedMessageLength(wire), wire.length); }); - test("truncated input does not hang — returns what was decoded", () => { - const input = Buffer.from("5\r\nhello\r\n3\r\nwor"); - const result = decodeChunked(input); - // First chunk "hello" is complete, second "wor" is truncated (expected 3 bytes, got 3 but no trailing \r\n) - // The implementation reads from nextCrLf+2 to nextCrLf+2+len, so it gets "wor" then tries to advance - // offset to nextCrLf+2+3+2 which is beyond buffer, loop exits - assert.equal(result.toString(), "hellowor"); + test("supports chunk extensions and trailers", () => { + const wire = Buffer.from("5;foo=bar\r\nhello\r\n0\r\nX-Test: yes\r\n\r\n", "latin1"); + assert.equal(decodeChunked(wire).toString("utf8"), "hello"); + assert.equal(getCompleteChunkedMessageLength(wire), wire.length); }); - test("empty input returns empty buffer", () => { - const result = decodeChunked(Buffer.alloc(0)); - assert.equal(result.length, 0); + test("returns null for incomplete wire data", () => { + assert.equal(getCompleteChunkedMessageLength(Buffer.from("5\r\nhel", "latin1")), null); + assert.equal(getCompleteChunkedMessageLength(Buffer.from("0\r\n", "latin1")), null); }); - test("only terminal chunk", () => { - const input = Buffer.from("0\r\n\r\n"); - const result = decodeChunked(input); - assert.equal(result.length, 0); - }); -}); - -describe("getCompleteChunkedMessageLength", () => { - test("returns full length for complete chunked message", () => { - const buf = Buffer.from("5\r\nhello\r\n0\r\n\r\n"); - assert.equal(getCompleteChunkedMessageLength(buf), buf.length); - }); - - test("returns null when terminal chunk is incomplete", () => { - const buf = Buffer.from("5\r\nhello\r\n0\r\n"); - assert.equal(getCompleteChunkedMessageLength(buf), null); + test("rejects malformed sizes and terminators", () => { + assert.throws(() => getCompleteChunkedMessageLength(Buffer.from("nope\r\n", "latin1")), /invalid chunk size/); + assert.throws(() => decodeChunked(Buffer.from("5\r\nhelloXX", "latin1")), /invalid chunk payload terminator/); }); - test("returns null when terminal chunk is split across packets", () => { - const partial = Buffer.from("5\r\nhello\r\n0\r"); - const full = Buffer.concat([partial, Buffer.from("\n\r\n")]); - assert.equal(getCompleteChunkedMessageLength(partial), null); - assert.equal(getCompleteChunkedMessageLength(full), full.length); - }); - - test("supports trailing headers after terminal chunk", () => { - const buf = Buffer.from("5\r\nhello\r\n0\r\nX-Test: 1\r\n\r\n"); - assert.equal(getCompleteChunkedMessageLength(buf), buf.length); - }); - - test("does not confuse chunk size 10 with terminal chunk", () => { - const buf = Buffer.from("10\r\n1234567890abcdef\r\n0\r\n\r\n"); - assert.equal(getCompleteChunkedMessageLength(buf), buf.length); + test("incrementally parses payloads and terminal chunk", () => { + const parser = new IncrementalChunkedBodyParser(); + assert.deepEqual(parser.push(Buffer.from("5\r\nhel", "latin1")), { payloads: [], terminal: false }); + const result = parser.push(Buffer.from("lo\r\n0\r\n\r\n", "latin1")); + assert.equal(result.payloads.length, 1); + assert.equal(result.payloads[0].toString("utf8"), "hello"); + assert.equal(result.terminal, true); + }); + + test("detects and strips terminal chunk lines", () => { + const text = "5\r\nhello\r\n0\r\n\r\n"; + const result = stripChunkSizeLines(text); + assert.equal(result.hasTerminal, true); + assert.ok(result.stripped.includes("hello")); + assert.equal(rawBytesHaveTerminal(Buffer.from(text, "latin1")), true); + assert.equal(rawBytesHaveTerminal(Buffer.from("10\r\n\r\n", "latin1")), false); }); });