Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a9819af
feat(agent-vault): enforce method and path rules, inject headers, sub…
saifsmailbox98 Sep 14, 2026
4280d80
fix(agent-vault): substitute against the escaped path and escape the …
saifsmailbox98 Sep 14, 2026
310a41e
fix(agent-vault): match encoded placeholders, accept our own path esc…
saifsmailbox98 Sep 15, 2026
a1dec16
refactor(agent-vault): call them custom headers on the proxy side too
saifsmailbox98 Sep 15, 2026
714b9bf
fix(agent-vault): refuse to forward a truncated body rather than rela…
saifsmailbox98 Sep 15, 2026
e39af56
fix(agent-vault): close two gaps review found in the substitution path
saifsmailbox98 Sep 15, 2026
1373902
test(agent-vault): pin the prefix boundary at depth
saifsmailbox98 Sep 15, 2026
8a39d8b
chore(agent-vault): cut the comments that narrate the code
saifsmailbox98 Sep 15, 2026
a8ecf56
chore(agent-vault): delete the comments that say what the code says
saifsmailbox98 Sep 15, 2026
a2d5e9f
fix(agent-vault): do not log a header value the substitution already …
saifsmailbox98 Sep 15, 2026
9e57377
fix(agent-vault): let an all-slashes path prefix fail closed
saifsmailbox98 Sep 17, 2026
3774b44
fix(agent-vault): swap the longest placeholder first
saifsmailbox98 Sep 17, 2026
0d261a1
fix(agent-vault): match an encoded placeholder in the query too
saifsmailbox98 Sep 17, 2026
11414a4
fix(agent-vault): refuse a segment that lands as traversal once trail…
saifsmailbox98 Sep 17, 2026
c305804
fix(agent-vault): refuse an echoing method whatever its case, and say…
saifsmailbox98 Sep 17, 2026
86bdc06
fix(agent-vault): strip the method-override headers where a service r…
saifsmailbox98 Sep 17, 2026
071cd6f
fix(agent-vault): three substitution defects review found
saifsmailbox98 Sep 17, 2026
8ef56d4
improvement(agent-vault): broker over plain http, not only https
saifsmailbox98 Sep 17, 2026
c268632
fix(agent-vault): four defects the review found in the request path
saifsmailbox98 Sep 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion packages/agentvault/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,29 @@ type credential struct {
password []byte
}

type customHeader struct {
name string
prefix string
value []byte
}

type substitution struct {
placeholder string
surfaces map[string]bool
value []byte
}

type resolvedService struct {
id string
name string
accessBundleName string
hostPatterns []hostPattern
credential credential
// A nil map means every method is allowed; an empty slice of prefixes means every path.
allowedMethods map[string]bool
allowedPathPrefixes []string
credential credential
customHeaders []customHeader
substitutions []substitution
}

type sessionEntry struct {
Expand Down
7 changes: 4 additions & 3 deletions packages/agentvault/match.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import (
"strings"
)

// A pattern with no port covers every port in Agent Proxy's grammar, which lets plaintext port 80
// through with the credential attached. Defaulting to 443 keeps that from happening here.
// A pattern with no port covers every port in Agent Proxy's grammar, which lets plaintext port 80 through
// with the credential attached. Defaulting to 443 keeps that from happening here, and is the whole of it:
// injection itself is scheme-blind, so naming a plaintext port is how an admin opts a service into it.
const defaultPort = "443"

// hostPattern carries no path: paths are rejected at write time, since the matcher would compare the
Expand All @@ -15,7 +16,7 @@ type hostPattern struct {
host string
port string
// Whether the entry named a port itself. Only the exception list reads this: a service without one
// has to stay on 443 or a credential would go out in the clear, but an exception carries no
// stays on 443, since that is what keeps a credential off plaintext, but an exception carries no
// credential, so a bare host there means the host rather than one port of it.
portWritten bool
}
Expand Down
281 changes: 281 additions & 0 deletions packages/agentvault/policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
package agentvault

import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"unicode/utf8"
)

var errPolicyBlocked = errors.New("blocked by service policy")

// The agent's own upload broke part way. Not a policy refusal and not an upstream failure, so it carries its
// own status rather than landing in either of theirs.
var errBodyUnreadable = errors.New("could not read the request body")

func checkServicePolicy(svc *resolvedService, req *http.Request) error {
if !svc.allowsMethod(req.Method) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restrict a service to GET and POST, and the agent sends a POST carrying X-HTTP-Method-Override: DELETE. Nothing in the package strips that header (grep returns zero hits), so Rails, Symfony and Laravel all perform the DELETE. The path checks here are deliberately paranoid about upstream quirks; the method check applies none of the same reasoning.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed, the three override headers get stripped.

scoped it to services that restrict methods though, rather than always: with no restriction the agent can send DELETE outright, so the header gains it nothing and there is no control to protect. shout if you would rather it always strip.

return fmt.Errorf("service %q does not allow %s: %w", svc.name, req.Method, errPolicyBlocked)
}
if len(svc.allowedPathPrefixes) > 0 {
path := requestPath(req)
if !pathAllowed(path, svc.allowedPathPrefixes) {
return fmt.Errorf("service %q does not allow path %q: %w", svc.name, truncatePath(path), errPolicyBlocked)
}
}
return nil
}

func (s *resolvedService) allowsMethod(method string) bool {
if s.allowedMethods == nil {
return true
}
return s.allowedMethods[strings.ToUpper(method)]
Comment thread
saifsmailbox98 marked this conversation as resolved.
}

// Rails, Laravel and Symfony all honour these, so a POST carrying one performs the method it names. The
// wire method is what the allowlist judged, so where there is an allowlist the header has to go.
func stripMethodOverrideHeaders(header http.Header) {
for _, name := range []string{"X-HTTP-Method-Override", "X-Method-Override", "X-HTTP-Method"} {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Form method override bypasses the allowlist

An authenticated proxy caller can send an allowed POST containing _method=DELETE in form data and have a Rails upstream execute the disallowed method; Rails forms use this hidden parameter for non-GET/POST methods. Deleting override headers does not inspect the query or request body, so the policy still authorizes only the wire method. Reject method-override parameters or derive and enforce the effective method before forwarding. citeturn0search0

header.Del(name)
}
}

// Whether Go would escape a byte appearing unescaped in a path. Derived from the standard library rather
// than transcribed from it: the table behind encodePath is generated, so a copy would be one Go release
// away from disagreeing with the rebuild this guards against.
var pathByteNeedsEscape = func() (table [256]bool) {
for b := 0; b < 256; b++ {
raw := string([]byte{byte(b)})
table[b] = (&url.URL{Path: raw}).EscapedPath() != raw
}
return table
}()

// EscapedPath falls back to rebuilding the path from its decoded form whenever RawPath is not valid
// encoding, and one literal '{' is enough. The rebuild turns '%2F' into a real '/', so hasUnsafeEscape
// never sees the escape it exists to refuse and the upstream receives a path the agent did not send.
// Escaping those bytes ourselves keeps RawPath valid, so EscapedPath returns it untouched. The wire form is
// unchanged either way: Go was already sending '%7B'.
func normalizeRequestTarget(u *url.URL) {
if u.RawPath == "" || u.EscapedPath() == u.RawPath {
return
}
escaped := escapeInvalidPathBytes(u.RawPath)
decoded, err := url.PathUnescape(escaped)
if err != nil {
return
}
u.Path = decoded
u.RawPath = escaped
}

// A '%' opening a valid triple is carried through, so an escape the agent wrote is never escaped twice. A
// malformed one cannot arrive: ParseRequestURI rejects it and the server answers 400 before the handler.
func escapeInvalidPathBytes(raw string) string {
var out strings.Builder
out.Grow(len(raw))
for i := 0; i < len(raw); i++ {
c := raw[i]
if c == '%' && i+2 < len(raw) {
if _, hiOk := unhex(raw[i+1]); hiOk {
if _, loOk := unhex(raw[i+2]); loOk {
out.WriteString(raw[i : i+3])
i += 2
continue
}
}
}
if pathByteNeedsEscape[c] {
const hexDigits = "0123456789ABCDEF"
out.WriteByte('%')
out.WriteByte(hexDigits[c>>4])
out.WriteByte(hexDigits[c&0x0f])
continue
}
out.WriteByte(c)
}
return out.String()
}

func requestPath(req *http.Request) string {
path := req.URL.EscapedPath()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Go’s EscapedPath() has a trap. If the path contains any byte Go thinks should have been encoded ({, }, |, ^, backtick, quote), it throws away what the agent actually sent and rebuilds the path from the decoded version, which turns %2F into a real /. hasUnsafeEscape exists specifically to refuse %2F. Adding one { anywhere in the path defeats it, because the %2F is already gone by the time the check runs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, and worse than the %2F part alone — the path we forwarded wasn't the one the agent sent. tested it on the wire with a raw dial, service pinned to /repos:

/repos/a%2Fb              -> 403
/repos/a%2Fb/{x}          -> 200, upstream got /repos/a/b/%7Bx%7D
/repos/a%2F..%2Fadmin/{x} -> 403

so the agent asked for one segment a%2Fb and github got two, with the credential on it. traversal was still caught by the .. and // checks on the rebuilt path, so it wasn't a prefix escape, but "we broker to a different resource than was asked for" is bad enough on its own.

couldn't just refuse a brace, since {{PAT}} is the placeholder syntax and that's the main path through this feature. so the bytes Go objects to get escaped before anything reads the path, which keeps RawPath valid and stops the rebuild happening at all.

two things worth knowing about the fix. it only runs where Go was going to rebuild anyway — !, (, ), *, [, ] are rejected by shouldEscape but accepted by validEncoded, so escaping those would have changed the wire and broken a byte-compared prefix for no reason. and the predicate is derived from the stdlib rather than copied out of it, because the table behind encodePath is generated and a hand copy would drift a release later.

the wire does change in one place, deliberately: a percent-triple the agent wrote is now carried through instead of decoded, so %41 survives the same way %2F does. that's the fix rather than a side effect.

same bug at rewrite.go, fixed by the same thing since it reads the corrected value.

if path == "" {
// forwardHTTP refuses an opaque target before this runs, so the branch is a floor under that check
// rather than a shape expected here. A genuinely empty path is the root.
if req.URL.Opaque != "" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

http:admin/secrets through the tunnel gives Go an empty path and a non-empty Opaque. The plain-HTTP handler rejects that shape, but the tunnel handler calls forwardHTTP directly and nobody checks. With no prefixes configured (the default) it goes upstream as GET admin/secrets HTTP/1.1, no leading slash, with a real Authorization header. Also, the comment on line 37 about OPTIONS * is wrong: Go answers that itself and never calls the handler.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're right that nobody checks it, and the shape reaches forwardHTTP through the tunnel while the plain door turns it away — though by way of the empty host rather than any opaque check, so the two doors disagreed by accident rather than by design. refused in forwardHTTP now, where both of them meet.

one thing it isn't, though: an access bypass. with no prefixes configured the service allows every path anyway, so the agent could have sent /admin/secrets normally and got the same credential. host pinning also holds, so it can't reach anywhere else.

what it actually cost is the record. forwardHTTP logged r.URL.EscapedPath(), which is empty for an opaque target, so the policy check saw admin/secrets, the upstream saw admin/secrets, and the log line showed nothing. the one request shape that looks deliberate was the one you couldn't see afterwards. the logged path comes from requestPath now, which can't be blank.

and the OPTIONS * comment is wrong, as you said — checked the stdlib, globalOptionsHandler takes it before our handler runs, for both the front server and the per-tunnel one. it's misplaced as well as stale: GET * parses to Path "*", which isn't empty, so the branch it annotates was never reachable for * either way. gone.

return req.URL.Opaque
}
return "/"
}
return path
}

func truncatePath(path string) string {
if len(path) > maxLoggedPathLen {
return path[:maxLoggedPathLen] + "...[truncated]"
}
return path
}

// Never decodes: anything whose meaning depends on the upstream's normalisation is refused outright, so the
// comparison below is a plain byte comparison.
func pathAllowed(escaped string, prefixes []string) bool {
if isAmbiguousPath(escaped) {
return false
}
return matchesPrefix(escaped, prefixes)
}

// Deliberately not isAmbiguousPath. The path here is part-written by us: applySubstitutions escapes the
// value so it cannot add a segment, and isAmbiguousPath refuses that very '%2F', so a secret like
// 'org/repo' would be rejected on its own escaping. Judged on the decoded path instead, which is both what
// an upstream decoding '%2F' will route on and the form our own escaping is invisible in. Everything below
// is a shape a substituted value could introduce; the agent's half of the path has already been through
// isAmbiguousPath on arrival.
func pathAllowedAfterSubstitution(escaped, decoded string, prefixes []string) bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two functions check the same path and disagree, and the weaker one guards the path that already carries the real secret. Wider than I first filed it; all four of these are refused before substitution and accepted after:

/repos//admin              pre=false  post=true
/repos/%00admin            pre=false  post=true
/repos/%09admin            pre=false  post=true
/repos/%C0%AE%C0%AE/admin  pre=false  post=true

An empty vault value turns /repos/PAT/admin into /repos//admin, and nothing validates that a substitution value is non-empty. The maintenance half matters too: lines 67 and 70-74 are hand-copies of 96 and 102-106, so hardening the first check silently skips the second.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

valid, and wider than the four you listed in one direction and narrower in another.

wider: the two functions disagree because the second one is weaker on purpose, and that wasn't written down. we PathEscape the substituted value so it can't add a segment, and isAmbiguousPath refuses that very escaping — I tried unifying them and it 403s our own output:

secret "org/repo" -> /repos/org%2Frepo/admin -> refused
secret "a%b"      -> /repos/a%25b/admin      -> refused

so they have to stay separate. what the second one was missing is the shapes a value can introduce, judged on the decoded path where our own escaping is invisible. //, control bytes and invalid UTF-8 are refused there now, and ghp_plain, org/repo, v1.2, a%b all still pass.

narrower: your empty-value trigger doesn't fire. secretValueSchema is .min(1) and blocks control chars, and the agent's half of the path has already been through isAmbiguousPath on arrival, so none of the four shapes can actually be produced today. it was latent, held shut by validation in the other repo, which isn't something this package should be leaning on — hence fixing it anyway.

one behaviour change worth flagging: a secret with a leading or trailing slash now fails this check rather than reaching the upstream as a doubled separator. /admin in /repos/{{PAT}}/x used to go out as /repos//admin/x. tested.

and yes on the maintenance half — the comment now says why the two differ, so it doesn't read as a hand-copy that drifted.

if strings.ContainsAny(decoded, ";\\") {
return false
}
// Normalises differently per server, and an empty value substituted mid-path is how it arises here.
if strings.Contains(decoded, "//") {
return false
}
for i := 0; i < len(decoded); i++ {
if decoded[i] < 0x20 || decoded[i] == 0x7f {
return false
}
}
// '%c0%ae' is an overlong '.', which some servers read as a dot and route on.
if !utf8.ValidString(decoded) {
return false
}
for _, segment := range strings.Split(decoded, "/") {
Comment thread
saifsmailbox98 marked this conversation as resolved.
if segment == "." || segment == ".." {
return false
}
}
return matchesPrefix(escaped, prefixes)
}

func matchesPrefix(escaped string, prefixes []string) bool {
for _, prefix := range prefixes {
if prefix == "/" {
return true
}
if !strings.HasPrefix(escaped, prefix) {
continue
}
if rest := escaped[len(prefix):]; rest == "" || rest[0] == '/' {
return true
}
}
return false
}

func isAmbiguousPath(escaped string) bool {
// Tomcat and Spring strip ;params before normalising, so /repos/..;/admin resolves to /admin upstream
// while reading as an ordinary segment here. IIS reads '\' as a separator.
if strings.ContainsAny(escaped, ";\\") {
return true
}
if hasUnsafeEscape(escaped) {
return true
}
for _, segment := range strings.Split(escaped, "/") {
if isDotSegment(decodeBenignEscapes(segment)) {
return true
}
}
// /a//b normalises differently per server.
return strings.Contains(escaped, "//")
}

// The UTF-8 check is what lets a real non-ASCII path through while still refusing the attack: `%c0%ae` is
// an overlong '.', which some servers read as a dot, while `%c3%a9` is a legitimate 'é'. Refusing every
// byte >= 0x80 would catch the first and break every API carrying a filename in its path.
func hasUnsafeEscape(escaped string) bool {
decoded := make([]byte, 0, len(escaped))
sawEscape := false

for i := 0; i < len(escaped); i++ {
if escaped[i] != '%' {
decoded = append(decoded, escaped[i])
continue
}
if i+2 >= len(escaped) {
return true
}
hi, hiOk := unhex(escaped[i+1])
lo, loOk := unhex(escaped[i+2])
if !hiOk || !loOk {
return true
}
b := hi<<4 | lo
if b < 0x20 || b == 0x7f {
Comment thread
saifsmailbox98 marked this conversation as resolved.
return true
}
switch b {
case '.', '/', '\\', ';', '%':
return true
}
decoded = append(decoded, b)
sawEscape = true
i += 2
}

// Only escaped input can carry an overlong sequence.
return sawEscape && !utf8.Valid(decoded)
}

// Runs after hasUnsafeEscape, so every escape still standing decodes to something harmless. Only the
// decoded form tells us whether a segment is all dots and spaces: "..%20" is not, ".. " is.
func decodeBenignEscapes(segment string) string {
if !strings.Contains(segment, "%") {
return segment
}
out := make([]byte, 0, len(segment))
for i := 0; i < len(segment); i++ {
if segment[i] != '%' || i+2 >= len(segment) {
out = append(out, segment[i])
continue
}
hi, hiOk := unhex(segment[i+1])
lo, loOk := unhex(segment[i+2])
if !hiOk || !loOk {
out = append(out, segment[i])
continue
}
out = append(out, hi<<4|lo)
i += 2
}
return string(out)
}

// Windows and IIS strip trailing dots and spaces from a segment, so anything built only from those reads
// as "." or ".." once it lands.
func isDotSegment(segment string) bool {
if segment == "" {
return false
}
for i := 0; i < len(segment); i++ {
if segment[i] != '.' && segment[i] != ' ' {
return false
}
}
return true
}

func unhex(c byte) (byte, bool) {
switch {
case c >= '0' && c <= '9':
return c - '0', true
case c >= 'a' && c <= 'f':
return c - 'a' + 10, true
case c >= 'A' && c <= 'F':
return c - 'A' + 10, true
}
return 0, false
}
Loading
Loading