This release includes 1 security fix for security teams reviewing exposed deployments.
Topics
Affected surfaces
Summary
AI summaryBroad release touches Upgrade notes, Bearer/bearer/BEARER/BeArEr, MAC/DPoP/Basic/PoP, and name.
Full changelog
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.
Breaking Changes
- Default `binding_source` changed to `both`; notes now win over file bindings.
Security Fixes
- SSRF guard now re‑checks token URL before each exchange (slice 5).
Weekly OSS security release digest.
The CVE patches and breaking changes that affected production tools this week. One email, every Sunday.
No spam, unsubscribe anytime.
Share this release
About AVP
All releases →Related context
Related tools
Beta — feedback welcome: [email protected]