← Back to PRs

#23110: feat(security): Credential Firewall — CredentialStore with domain pinning (1/4)

by ihsanmokhlisse open 2026-02-22 01:40 View on GitHub →
size: XL
## Summary - **Problem:** When an agent fills a password in the browser, the credential value appears in the LLM context window, session transcripts, agent events, and plugin hooks. Prompt injection attacks can exfiltrate credentials to attacker-controlled domains. - **Why it matters:** This is the #1 security gap in agent-driven browser automation. No AI agent framework has solved this. - **What changed:** Added `CredentialStore` with domain pinning — credentials are bound to specific domains and can only be injected when the browser URL matches. This is PR 1 of 4 for the Credential Firewall (issue #18245). - **What did NOT change:** No changes to existing code. This PR adds 5 new files in `src/credentials/`. ## Change Type (select all) - [x] Feature - [x] Security hardening ## Scope (select all touched areas) - [x] Auth / tokens - [x] Skills / tool execution ## Linked Issue/PR - Related #18245 (Credential Firewall) - Related #16663 (SecretsProvider interface) - Related #23096 (Bitwarden provider — same author) ## How the Credential Firewall Works The agent says `fill_credential("gmail", "#password")` — only a slot name, never the actual password. The `CredentialStore` (this PR): 1. Looks up the slot in configuration 2. Checks if the credential has expired 3. Verifies the browser URL matches the pinned domains 4. Verifies the CSS selector is in the allowlist 5. Resolves the credential value from any SecretsProvider backend 6. Returns the value (only to the fill tool, never to the LLM) ``` Agent: fill_credential("gmail", "#password") → CredentialStore.resolve({ slot: "gmail", currentUrl: "https://accounts.google.com", selector: "#password" }) → Domain check: accounts.google.com ∈ ["accounts.google.com", "mail.google.com"] ✓ → Selector check: #password ∈ ["#password", "input[type=password]"] ✓ → Resolve: ${bw:gmail-account/password} → "actual-password" → Return to fill tool (NOT to LLM) Agent: fill_credential("gmail", "#password") on evil.com → Domain check: evil.com ∉ ["accounts.google.com", "mail.google.com"] ✗ → BLOCKED — credential never resolved ``` ## Provider Compatibility Tested with mock resolvers for ALL secret provider syntaxes: | Provider | Syntax | Tested | |---|---|---| | Bitwarden | `${bw:item/field}` | ✓ | | OS Keyring | `${keyring:name}` | ✓ | | 1Password | `${op:vault/item/field}` | ✓ | | GCP Secret Manager | `${gcp:name}` | ✓ | | AWS Secrets Manager | `${aws:name}` | ✓ | | HashiCorp Vault | `${vault:path}` | ✓ | | age/sops | `${age:file#path}` | ✓ | | Environment | `${env:VAR}` | ✓ | | Plain text | `"raw-value"` | ✓ | ## Config Example ```json5 { "credentials": [ { "slot": "gmail", "source": "${bw:gmail-account/password}", "pinnedDomains": ["accounts.google.com", "mail.google.com"], "allowedSelectors": ["#password", "input[type=password]"], "label": "Gmail login" } ] } ``` ## User-visible / Behavior Changes New `credentials` config section (opt-in, no behavior change if not configured). ## Security Impact (required) - New permissions/capabilities? `No` - Secrets/tokens handling changed? `Yes` — adds credential store with domain pinning enforcement - New/changed network calls? `No` - Command/tool execution surface changed? `No` (this PR is store-only; the tool comes in PR 2/4) - Data access scope changed? `No` - Risk + mitigation: - Credential values are NEVER logged in the audit trail (only slot names, domains, timestamps) - Domain pinning prevents credential injection on unauthorized domains - Selector allowlisting prevents filling into visible text fields - Expired credentials are rejected before resolution - Provider errors are caught and re-thrown without exposing credential values ## Evidence - [x] 54 unit tests, all passing (20 domain-match + 34 store) - [x] 0 lint errors (oxlint) - [x] 0 format issues (oxfmt) - [x] TypeScript compiles clean - [x] Tests cover: happy path (4), provider compatibility (8), domain pinning (4), selector check (2), slot not found (1), expiry (2), provider failure (2), audit log (4), constructor validation (4), listSlots/getEntry (3) ## Human Verification (required) - Verified: All 54 tests pass locally, typecheck clean, lint clean, format clean - Edge cases: invalid URLs, empty configs, expired credentials, wildcard domains, deep subdomains, multiple pinned domains, audit log cap at 1000 entries - What I did **not** verify: Integration with real browser (that's PR 2/4). This PR is the storage + validation layer only. ## Compatibility / Migration - Backward compatible? `Yes` — entirely opt-in - Config/env changes? `Yes` — new `credentials` config section (unused if not configured) - Migration needed? `No` ## Failure Recovery (if this breaks) - How to disable: Remove `credentials` section from config - Files to restore: None (new files only) - Known bad symptoms: `CredentialFirewallError` with specific error codes (SLOT_NOT_FOUND, DOMAIN_BLOCKED, SELECTOR_BLOCKED, EXPIRED, RESOLVE_FAILED) ## Risks and Mitigations - Risk: SecretsProvider interface (PR #16663) may change - Mitigation: `SecretResolver` is a simple `(source: string) => Promise<string>` function — minimal coupling - Risk: Domain pinning could be too restrictive for some sites - Mitigation: Wildcard domains (`*.example.com`) supported; empty allowedSelectors means any selector allowed ## Roadmap (4 PRs total) 1. **This PR** — CredentialStore with domain pinning (storage + validation) 2. **PR 2/4** — `fill_credential` agent tool (Playwright integration) 3. **PR 3/4** — Context scrubbing (redact credentials from transcripts/events) 4. **PR 4/4** — CLI, Web UI, security audit integration ## AI-Assisted - [x] This PR was AI-assisted (Claude) - [x] Fully tested (54 unit tests) - [x] I understand what the code does - [x] Verified against Bitwarden CLI docs and OpenClaw browser tool architecture Made with [Cursor](https://cursor.com) <!-- greptile_comment --> <h3>Greptile Summary</h3> Added `CredentialStore` with domain pinning to prevent credential exfiltration via prompt injection attacks. Credentials are bound to specific domains and validated before injection, keeping actual values out of the LLM context. **Key changes:** - New credential storage system with domain pinning, selector allowlisting, and expiry checks - Comprehensive test coverage (54 tests) for all security boundaries - Support for multiple secret provider backends (Bitwarden, 1Password, keyring, cloud providers) - Audit logging that never exposes credential values **Issues found:** - Wildcard domain matching (`*.example.com`) inconsistent with existing SSRF protection in `src/infra/net/ssrf.ts` — currently allows matching the base domain itself, but the existing security pattern explicitly blocks this <h3>Confidence Score: 3/5</h3> - Safe to merge after fixing the wildcard domain matching inconsistency - The implementation is solid with excellent test coverage and proper security controls. However, the wildcard domain matching logic conflicts with the existing SSRF protection pattern, which is a security-sensitive inconsistency that should be resolved before merge. Once fixed, this will be a strong security enhancement. - Pay close attention to `src/credentials/domain-match.ts` (wildcard matching logic) and `src/credentials/domain-match.test.ts` (test expectations for wildcard behavior) <sub>Last reviewed commit: de016ba</sub> <!-- greptile_other_comments_section --> <sub>(3/5) Reply to the agent's comments like "Can you suggest a fix for this @greptileai?" or ask follow-up questions!</sub> <!-- /greptile_comment -->

Most Similar PRs