v0.7.0
Added
-
Host-validation hardening + wildcard opt-in. Binding hosts must name a real destination: empty, whitespace, bare/embedded
*, malformed, and public-suffix wildcards (*.com,*.co.uk,*.github.io) are rejected at config-load. Wildcard hosts are now opt-in via the top-levelallow_wildcard_hosts: true— off by default, since a wildcard widens a secret's blast radius to every subdomain. -
Full-feature docker-e2e harness. The
tests/docker-e2eharness now covers all 12 features end-to-end on an isolated bridge (header/body/multi/composite injectors, scope-violation, fail-closed, deny, host-validation, forward_unmodified, SNI-mismatch, binding_source, and oauth2 with a hermetic mock token endpoint). Runs as the CIe2e-dockerjob. -
docs/ROADMAP.md. Forward-looking work, linked to the design ADRs (GCP backend, off-box audit shipping, TLS-passthrough scoping, metrics/healthz, artifact signing/SBOM, reserved injector types). -
OAuth2 RFC 6749 §5.1 + RFC 6750 §1.2 token_type validation (post-slice-9 hardening). The success-response parser now reads
token_typeand rejects anything other than case-insensitive "Bearer" with a newunsupported_token_typeoutcome on thetoken_exchangeaudit event. Without this, a token endpoint that returned a legacy MAC token, vendor DPoP, or "PoP" scheme would have been silently injected asAuthorization: Bearer <mac-or-dpop-token>— either confusing the upstream with a 401 or, worse, leaking credentials in a malformed scheme. Strict on present-but-wrong; lenient (accept with warning log) on absent — being strict on absent would break real providers that omit the RFC-REQUIRED field, with no security gain since AVP only knows how to inject Bearer anyway. Three new tests pin the case-insensitive accept (Bearer/bearer/BEARER/BeArEr), the rejection set (MAC/DPoP/Basic/PoP), and the absent-token_type lenient path. Closes the deferred hardening item from the slice-9 blind-spot pass. -
Stateful no-leak property test for oauth2_refresh (
tests/test_addon_oauth2_noleak_stateful.py). Mirrors the existingtest_addon_noleak_stateful.pyHypothesis pattern but exercises the oauth2_refresh path — which has its own derived-token cache + write-back chain on top of the shared addon pipeline. ARuleBasedStateMachineinterleavesrequest,rotate(stage an upstream rotation),reload(rebuild viaconfigure_from_pathand flush the derived-token cache), and scope changes. The independent oracle (hand-written, no policy import) catches the case where the matcher and the addon drift TOGETHER. Invariants: every secret value that EVER existed in the vault for this binding — initial refresh token, every rotated refresh token, every access token issued, the client secret — must be absent from the audit log across every interleaving; the vault always holds either the initial refresh token or one of the staged-rotation values (never malformed). 25 examples × 20 steps per example green. -
OAuth2 worked example + end-to-end test suite (ADR-0017 slice 9 + post-review hardening).
bindings.example.yamlgains a workedGOOGLE_OAUTHentry using thegoogleprovider preset, with explicit operator-facing prose calling out the lockout footgun (read-only backend + auto-rotation = self-destruct on first request —avp doctor --probe-oauthflags it).tests/test_oauth2_refresh_e2e.pyadds 7 end-to-end tests driving a real localhttp.server.HTTPServerthrough the full addon pipeline via the productionconfigure_from_pathcodepath (NOT synthetic addon construction): happy-path with real backend + real urllib + real audit file; cache-hit (2 requests, 1 upstream call); refresh-token rotation persists to vault and audit; invalid_grant → 503 denial; forwarded-unmodified verbatim path;avp doctor --probe-oauth --exchangeagainst the live server (rotation = WARN, no write-back); plus a companion regression test that the YAML fixture'sbackend:block actually constructs throughbuild_backendwithout patches. The DNS-stub fixture passes through IP literals + loopback to the real resolver so the in-process mock server is reachable (the SSRF guard'ssocket.getaddrinfomonkey-patch globally affectshttp.clienttoo; naive "everything is public" stub misroutes loopback). The TLS layer is deliberately out of scope (mitmproxy + urllib own it); the test docstring is explicit about plaintext-transport coverage. Post-Oracle / Silas adversarial-review hardenings applied inline: (1) post-call SSRF re-check onresp.geturl()— urllib follows 3xx by default, so a token endpoint that redirects to a private address (169.254.169.254, 10.x.x.x, ULA, …) would otherwise bypass the request-time guard; we now reject the response asssrf_blockedwhen the final URL diverges from the configured one and resolves internal. The on-the-wire visit still happens (a fully-redirect-disabling fix requires test-suite refactoring around the urlopen patch boundary; tracked in the hardening patch series); (2) explicitUser-Agent: agent-vault-proxy/oauth2-refresh— some providers block stockPython-urllib/3.Xwith confusing 4xx errors; identifying AVP also lets providers whitelist explicitly; (3) audit-field allowlist guard — the existing "secret bytes substring-absent" check is necessary but not sufficient (encoded variants — base64, hashed, unicode-escaped — would defeat substring search); a positive allowlist on event field names forces any new audit field through code review. Two new exchange tests cover the redirect rejection (vs. public-redirect acceptance) and the User-Agent header. Known pre-existing bug surfaced during this work:_ssrf_guard.check_url_not_internal's IP-literal short-circuit'sexcept ValueErrorcatchesSsrfBlockedError(because the latter IS-A ValueError) — tracked separately since the production loop check atgetaddrinfotime still blocks correctly under real DNS. Hardening patch series remaining: pending-audit-before-write (Oracle F2), BWSrevision_dateprecondition (Oracle F3), per-binding write-back rate limit (Oracle F4), SsrfBlockedError exception inheritance refactor, fully-redirect-disabling implementation. -
avp doctor --probe-oauthCLI subcommand (ADR-0017 slice 8). Operator self-service: "is this OAuth2 binding wired correctly?" without driving a proxied request through an agent. Read-only by default. Per-binding checks:ssrf(re-runs the SSRF guard ontoken_urlat probe time — config-load already passed, but DNS could rebind),input:<role>× 3 (the three vault inputs —client_id_secret,client_secret_secret,refresh_token_secret— fetched through the configured backend; empty value or fetch failure rolls up FAIL),writable(backend exposesupdateso slice-7 write-back is reachable — read-only backend withrefresh_token_write_back: trueis a FAIL since a rotation would lock the binding out; withrefresh_token_write_back: falseit is a WARN since the operator opted in to the lockout). Opt-in--exchangeflag adds a fifth check that actually calls the upstream token endpoint — most providers will rotate the refresh token on a successful exchange, so a probe with--exchangeis state-mutating against the upstream and the probe deliberately reports a rotation as WARN (the probe does NOT write-back to the vault; if rotation happens, the operator must manually update the vault secret with the new value). Status taxonomy:OK/WARN/FAIL/SKIP; FAIL alone rolls up to exit code 1. New CLI flags:--probe-oauth,--binding NAME(restrict to one binding),--exchange(live call),--config(the existing daemon config path). Defaultavp doctor(no--probe-oauth) MUST NOT load the config or build the backend — verified by test so a missing/mis-permissioned config can never break the CA-only flow. Implementation lives incli/doctor_oauth.py(separation mirrorscli/env.py). Operator-visible messages report secret-name and byte-length only — never the secret value. 21 tests cover discovery, the unknown/wrong-type filter, every per-check happy and failure path, the rollup, SKIP-on-prior-input-failure, rotation-as-WARN on live exchange, and the two CLI integration paths (probe+OK = exit 0, probe+FAIL = exit 1, default = doesn't build backend). -
OAuth2 refresh-token shape guard (ADR-0017 slice 7 hardening, post-review). A compromised or MITM'd token endpoint that returns a junk
refresh_tokenfield (single byte, control chars, oversized blob) could otherwise drive the proxy into PUTing that value into BWS and permanently bricking the binding — the prior value is overwritten with no live backup, and an operator's only recovery is a manual rotation against the upstream. The new_is_well_formed_refresh_tokenguard inaddon.pyenforces RFC 6749 §A.17vschar(printable ASCII 0x20-0x7E) and a generous length envelope (8 - 4096 bytes — covers every public provider's token format today). A rotated token failing the shape check skips the write-back entirely and emits a fifth outcome on the samerefresh_token_rotatedaudit event:write_back_rejected_malformed(error_type: "malformed_refresh_token"). The current request still serves the upstream-issued access token; the next exchange will surface the upstream/vault drift via the standardtoken_endpoint_error:invalid_grantpath. 6 parametrised malformed-input tests cover too-short, too-long, embedded NUL, tab, newline, and non-ASCII payloads — all reject without mutating the vault. Surfaced jointly by Oracle (F1, HIGH) and a separate offensive-security pass (vault-poisoning, MED). Two follow-up items remain deferred to a slice 7+ patch series: (1) emitting arefresh_token_rotated:pendingaudit BEFORE the backend PUT (closes the mid-write crash / audit-emit failure ordering gap — Oracle F2, Silas #6); (2) per-binding write-back rate limit to bound BWS PUT pressure under a forced-rotation DoS (Oracle F4, Silas #5); (3) BWSrevision_dateprecondition on the PUT to detect operator-side manual rotation that would otherwise be silently clobbered (Oracle F3, Silas #3). -
OAuth2 refresh-token write-back (ADR-0017 slice 7). Closes the loop on refresh-token rotation. When the upstream issues a new refresh token alongside the access token (
ExchangeResult.new_refresh_token is not None), the addon persists it back to the vault via the slice-3backend.updateProtocol method, then invalidates the cached read on the wrappingCachingSecretsClientso the next access-token expiry inside the existingttl_secondswindow doesn't reuse a value the upstream just invalidated (without that flush, the binding would silently lock out for up tottl_seconds). NewCachingSecretsClient.update_secret(name, value, ctx)is the write-through helper — delegates to the module-levelupdate_secretdispatch (raisesBackendNotWritableErrorfor read-only adapters), then callsself.flush(name)on success so the generation counter bump catches any in-flight singleflight fetch. Four outcomes, each on a singlerefresh_token_rotatedaudit event:success(backend update returned),write_back_disabled(operator opted out viarefresh_token_write_back: false),write_back_unavailable(backend is read-only — structural configuration error),write_back_failed(backendupdateraised — transient; the access token is STILL served on this request because killing it after we already hold a valid token would compound the failure). Audit event carriesbinding_name,refresh_token_secret(bindings.yaml reference name),outcome, and anerror_typeclass-name field on failure outcomes — never the refresh-token VALUE (old or new), never the exception message (a backend could surface credential material in__str__). Audit ordering preserved end-to-end:token_exchange→refresh_token_rotated→inject_decision. Cache hits skip the rotation path entirely — the write-back already happened during the miss. The sameExchangeResult.new_refresh_tokensemantic from slice 5 (set only when upstream issued a DIFFERENT value, never on an echoed-back identical refresh token) keeps the path quiet for providers that don't rotate. 9 end-to-end tests pin per-outcome audit shape, ordering, vault-cache flush after success, best-effort serving on write-back failure, and the cache-hit silence invariant.avp doctor --probe-oauth(slice 8) will surfacewrite_back_unavailableandwrite_back_disabledconfigurations distinctly. -
OAuth2 refresh-token addon dispatch (ADR-0017 slice 6). The mitmproxy addon now executes an
oauth2_refreshallowedverdict end-to-end.policy.decidereturns a populatedoauth2_injectorfield on theDecision; the addon's_execute_header_decisionbranches on that field to_execute_oauth2_refresh_decision, which (1) fetches the three vault inputs (client_id_ref/client_secret_ref/refresh_token_ref) — vault failure routes to the existingsecret_unavailable/secret_fetch_erroraudit shape, no token exchange ever happens; (2) builds aKeyInputsover the bindings, runsDerivedTokenCache.dedup_or_fetchso N concurrent agent requests for the same binding share a single upstream exchange; (3) on miss invokes the slice-5exchange()and emits a newtoken_exchangeaudit event (outcometaxonomy from slice 5, plusbinding_name,token_url_host,cache_ttl_effective_seconds,used_default_expiryfor the under-spec providers; deliberately carries zero secret material), then the existinginject_decisionaudit; (4) on miss + failure routes a 503 +token_exchange_failed:<outcome>denial through the sameinject_decisionpath so downstream audit consumers don't need a new event-type branch to count token-related denials. Thetoken_exchangeaudit fires BEFORE the proxied request bytes leave AVP — the G6 audit-before-action invariant carries through to the new event. Cache hits skip bothexchange()and thetoken_exchangeaudit (the event signifies an upstream call happened; a cached hit is a no-op event-wise).configure_from_pathrebuilds the cache on every reload — a stale refresh token must not survive a config rotation. 8 end-to-end tests pin cache miss success + audit ordering, audit-shape metadata (no secrets), cache hit silence,invalid_grantdenial path, network-failure denial path, runtime SSRF rebind (the slice-5 re-check fires before urlopen), vault input failure (never reaches exchange), and reload-resets-cache. Write-back path,avp doctor --probe-oauth, and the worked example land in slices 7-9. -
OAuth2 token exchange
injectors/oauth2_refresh.py(ADR-0017 slice 5). The synchronous core of the resolution step. Re-runs the SSRF guard onspec.token_url(DNS rebinding defense — a name that resolved public at config-load can resolve private later), builds an RFC 6749 §6 refresh-token grant POST (form-encoded body,client_auth_method: body_postputs credentials in body,basicputs them in theAuthorizationheader), issues one POST via stdliburllib.request.urlopenwith HTTPS enforced by the schema validator, parses the response per §5.1 / §5.2. Outcome taxonomy:success,token_endpoint_error:<code>(any RFC 6749 §5.2errorcode surfaced verbatim —invalid_grant,invalid_client,invalid_scope, vendor extensions),token_endpoint_status:<status>(non-JSON 4xx/5xx body),token_endpoint_unreachable(network errors +TimeoutError),response_parse_failed,ssrf_blocked. One retry on 5xx + network errors with a 1 s backoff; no retry on 4xx.expires_inis honoured when present, capped bycache_ttl_max_secondsafter subtractingcache_ttl_safety_seconds; when absent, the cache_ttl_max is used andused_default_expiry=Trueis surfaced so the audit layer can flag under-spec providers. Refresh-token rotation detected via not-equal check (echoed-back identical refresh tokens are NOT a rotation — write-back must skip in that case).exchange_asyncdispatches the sync core throughloop.run_in_executoron a dedicated 8-thread pool so a slow token endpoint never blocks the mitmproxy asyncio loop. 18 tests pin success/rotation/error/retry/SSRF/off-thread behaviour. The addon wire-up and audit emission land in slice 6. -
SSRF guard
_ssrf_guard.py(ADR-0017 slice 2). Module exposingcheck_url_not_internal(url)that resolves the host (or accepts an IP literal directly) and refuses any address in loopback, RFC 1918 private, link-local (incl. AWS IMDS at 169.254.169.254), CGNAT 100.64/10, IPv6 ULA fc00::/7 (incl. IMDSv6 at fd00:ec2::254), unspecified, multicast, or reserved ranges. Fail-closed on DNS resolution failure and on mixed-result resolutions (a hostname returning any private record is blocked — DNS-rebinding defense). Wired intoOauth2RefreshInjectorat config-load on every operator-suppliedtoken_url(both the fully-explicit form and the tenant-specific preset form). 27 tests pin per-range behaviour and the config-load integration; the runtime DNS re-check before each token exchange lands in slice 5. -
SecretsBackend.update(name, value, ctx)Protocol extension +update_secretdispatch helper (ADR-0017 slice 3). Optional method (same opt-in shape asfetch_with_meta) — backends that implement it support write-back, backends that don't raise the newBackendNotWritableError(subclass ofBackendUnavailableErrorso the existing fail-closed catch-all in the addon already handles it).BitwardenBackend.updateissues a GET-then-PUT preservingnoteandproject_idsso binding metadata isn't blanked on refresh-token rotation.StaticSecretsBackendis intentionally read-only — confirmed by test. The runtime write-back call site lands in slice 7. 7 tests. -
_DerivedTokenCache(ADR-0017 slice 4). Sibling ofCachingSecretsClient— vault secrets and derived OAuth access tokens have incompatible invalidation, expiry, and audit-attribution semantics so they live in separate clients. Cache key is HMAC-SHA256 (per-instance salt) over(binding_name, token_url, scopes, client_id_value, refresh_token_value)so any rotation, scope edit, or config reload produces a different key — a stale token can't be served after the inputs that minted it have changed. Per-entry TTL from the caller-suppliedexpires_at. Inflight Future-per-key dedup makes N concurrent agent requests for the same binding trigger exactly one upstream exchange. Lazy expiry on read + proactive sweep onputkeep the dict bounded across rotations. Best-effort zero-on-flush; the G10 invariants (mlock,RLIMIT_CORE=0, no swap, UNIX-user isolation) carry the at-rest defence. 11 tests including a concurrency pin on the inflight dedup. -
inject.type: oauth2_refresh(schema only, slice 1 of ADR-0017). The OAuth2 refresh-token grant injector (RFC 6749 §6) — schema, discriminated-union dispatch, bundled provider preset catalogue (google,microsoft,auth0,slack,atlassian,okta), HTTPS-onlytoken_urlvalidator, preset-or-explicit XOR, three-required-secret refs, defaults pinned (Authorization: Bearer {access_token},cache_ttl_safety_seconds=60,cache_ttl_max_seconds=3600,refresh_token_write_back=True). The priorplanned: P1config-load error for thistype:no longer fires. Runtime resolution (derived-token cache, off-thread token exchange, audit emission, write-back, doctor probe) lands in subsequent slices; existing parity meta-test (test_canonical_schemas_cover_implemented_injector_types) gains two new canonical schemas (preset + explicit) so future drift surfaces immediately. ADR full text atdocs/adrs/ADR-0017-oauth2-refresh-injector.md. -
docs/adrs/directory with aREADME.mddocumenting the in-repo ADR convention (MADR-lite frontmatter; one filename pergit log docs/adrs/-able decision). ADR-0017 is the first entry. Pre-ADR-0017 ADRs (0011 BWS-notes, 0012 narrow-trust CA, 0013 declarative policy fixtures) get backfilled here as the affected code paths next get touched. -
Composite body bindings.
inject.type: bodynow acceptsinject.template(Jinja2-sandboxed) alongsidecompose:, mirroring the header composite path. SameSecretSpec.compiled_templatemachinery; the placeholder in the request body is replaced with the rendered template output rather than a single secret value. Streaming-mode invariants (constant memory, chunk-boundary correctness, fail-closed on backend failure) are preserved — the composite resolver hooks into the_BodyReplacer's lazy per-target fetch and reuses the addon's existing_fetch_and_render_composite(composite_unavailable/composite_fetch_error/render_failedaudit shapes carry across, with thecompose:list on failure). The P0.6 BodyInjector validator's deferral message ("composite/Jinja is not yet supported") is removed; bindings that previously failed config-load with that message now compile and render. -
Template helpers
hmac_sha1(key, msg)andtotp(secret_b32).hmac_sha1mirrors the existinghmac_sha256/hmac_sha512shape (UTF-8 in, lowercase hex out).totpimplements RFC 6238 with the SHA-1 default, 30-second window, 6-digit output, dynamic truncation per RFC 4226 §5.3; base32 input is normalised (case-insensitive, whitespace and=padding tolerated). TOTP is the single non-deterministic helper in the sandbox — it depends ontime.time()— and is documented as such in the helper's docstring. RFC 6238 §5.2 reference vectors are pinned intests/test_template.py; the RFC 2202 §3 case-1 vector pinshmac_sha1. The motivating case is APIs that require a live 2FA code in the request — e.g. Kraken's private endpoints take the current TOTP as theotpbody field — computed against a secret AVP holds, not the agent.
Upgrade notes
- Reserved helper names. Adding
hmac_sha1andtotpto the sandbox extends the set of names a composite template'scompose:entries must not collide with. Bindings whosecompose:list contains a literal entry namedhmac_sha1ortotp(case-sensitive) will now fail at config-load withallowed_vars entry … collides with reserved function …; rename your compose entry. The same rule has always applied tob64encode/b64decode/sha256/urlencode/hmac_sha256/hmac_sha512; rename the offending compose entry (e.g.HMAC_SHA1→HMAC_SHA1_VALUE) and the binding will load again. AVP daemon refuses to start on a broken config, so this is an immediately-visible failure on upgrade — not a silent regression. - TOTP wall-clock dependence. Bindings using
totp(...)produce a different output every 30 seconds and depend on the host clock being in sync with the upstream's TOTP authority. Verifychronyc trackingis healthy on the AVP host before relying on atotp-using binding in production; a stale or drifted host clock will produce codes the upstream silently refuses. - BWS-notes bindings (ADR-0011): bindings resolve from each secret's
notesfield (host:required; optionalheader/format/methods/paths, defaulting toAuthorization: Bearer). Bundled host-keyed exception table for known providers ships tight defaults (GitHub read-only — noPOST /gists). Fail-closed, with distinct audit reasonsno_binding_in_notesvsinvalid_binding_metadata. avpoperator CLI:avp envprojects salted placeholders to~/.config/avp/env(sourced, nevereval'd);avp doctorchecks the CA is not in any OS trust store and the CA key is0600(ADR-0012).- Deterministic salted placeholders (
avp-PLACEHOLDER-…, HMAC keyed by a per-install salt) andbinding_source: file | bws_notes | both. avp setupcross-platform installer (systemd/launchd, isolated service user, append-only audit log);--no-serviceprovisions without activating.tests/setup-e2e/bats suite +e2e-setupCI job assert the installed state in a disposable container.
Changed
- Default
binding_source: both— BWS-notes resolve alongsidebindings.yaml, notes winning per secret.filemode is byte-identical to prior behaviour. - Audit contract v2: adds
binding_source; the audit log still records no header values, bodies, or query strings.