package console import ( "errors" "fmt" "net/url" "strings" ) // The callback path: what an app may ask the console to POST to, and why it is // a path rather than a URL. // // The console is an authenticated request generator. It holds credentials no // tenant has, it sits inside the deployment's network, and it will make an // outbound request because an app asked it to. If an app could name the HOST, // that is the whole of SSRF handed over in a JSON field — so it names a path, // and the console supplies the host from the app's platform-assigned name. // // Everything refused below is a way of smuggling a host, a header, or a // different resource through a field that is supposed to be a path. Spec §6.2. // maxCallbackPath is 1KB. Not a security boundary on its own — every hostile // shape below is refused at any length — but a durable field an app controls // wants a bound, and a callback path longer than a kilobyte is a mistake // rather than a requirement. const maxCallbackPath = 1024 var errCallbackPath = errors.New("console: callback path") // validCallbackPath is the rule. It runs at the DOOR, on the way in, so that // nothing hostile is ever stored — a validator that runs at delivery time // leaves every already-accepted item in the bucket to be found later. // // IT CHECKS BOTH SPELLINGS OF EVERY RULE: the raw string the app sent AND the // decoded path url.Parse produces. The first version of this function checked // only the raw string, and an external security review walked straight past it // with "/%5c%5cevil/x" and "/%2f%2fevil/x" — percent-encoded forms of two // shapes it explicitly refused in plaintext. That is AGENTS.md's own rule // turned on us: a rule stated as an absence can only be checked by looking for // the absent thing, and cannot see a second door. url.Parse IS the second // door. func validCallbackPath(p string) error { switch { case p == "": return fmt.Errorf("%w: empty", errCallbackPath) case len(p) > maxCallbackPath: return fmt.Errorf("%w: longer than %d bytes", errCallbackPath, maxCallbackPath) } // ASCII ONLY, and this is the rule that closes the normalisation family. // A receiver that applies Unicode compatibility normalisation turns // U+FF0E FULLWIDTH FULL STOP into ".", so "/../admin" becomes a // traversal on arrival while looking like nothing here. We cannot know // what the receiving stack normalises, and the set of confusables is not // something to enumerate — so a callback path is printable ASCII, and an // app that wants anything else has to say it in the query string, where // it stays percent-encoded and is never a path segment. for i := 0; i < len(p); i++ { if c := p[i]; c < 0x20 || c > 0x7e { return fmt.Errorf("%w: byte %#02x at %d is not printable ASCII", errCallbackPath, c, i) } } // Percent-encoded control bytes, which survive to whatever decodes them. // Case-insensitive, because %0D and %0d are the same byte. lower := strings.ToLower(p) for _, enc := range []string{"%0d", "%0a", "%00"} { if strings.Contains(lower, enc) { return fmt.Errorf("%w: encoded control byte %s", errCallbackPath, enc) } } // The raw shape. "//evil/x" is protocol-relative and "/\evil/x" is treated // as protocol-relative by enough parsers to count. switch { case !strings.HasPrefix(p, "/"): return fmt.Errorf("%w: must begin with /", errCallbackPath) case strings.HasPrefix(p, "//"): return fmt.Errorf("%w: protocol-relative", errCallbackPath) case strings.ContainsRune(p, '\\'): return fmt.Errorf("%w: backslash", errCallbackPath) case strings.Contains(p, "#"): return fmt.Errorf("%w: fragment", errCallbackPath) } u, err := url.Parse(p) if err != nil { return fmt.Errorf("%w: %v", errCallbackPath, err) } // The spec's post-parse invariant, stated as an invariant. If any of these // is non-empty the string named something other than a path on this host, // whatever route it took to get there. if u.Scheme != "" || u.Host != "" || u.User != nil || u.Opaque != "" { return fmt.Errorf("%w: names a host or scheme", errCallbackPath) } // NOW THE SAME RULES AGAIN, on the decoded path. Everything below is a // shape that is refused above in plaintext and was reachable through // percent-encoding until an external review pointed it out. switch { case !strings.HasPrefix(u.Path, "/"): return fmt.Errorf("%w: decodes to something that is not a path", errCallbackPath) case strings.HasPrefix(u.Path, "//"): return fmt.Errorf("%w: decodes to a protocol-relative path", errCallbackPath) case strings.ContainsRune(u.Path, '\\'): return fmt.Errorf("%w: decodes to a path containing a backslash", errCallbackPath) case strings.ContainsAny(u.Path, "#?"): return fmt.Errorf("%w: decodes to a path containing a delimiter", errCallbackPath) } for i := 0; i < len(u.Path); i++ { if c := u.Path[i]; c < 0x21 || c > 0x7e { return fmt.Errorf("%w: decoded path byte %#02x at %d is not printable ASCII", errCallbackPath, c, i) } } // SEMICOLONS. A servlet-style receiver strips ";params" from each segment // BEFORE it normalises the path, so "/safe/..;ignored/admin" arrives as // "/safe/../admin" and traverses — while every check that splits on "/" // and compares to ".." sees the harmless segment "..;ignored". Refused // outright: a callback path has no legitimate use for one. if strings.ContainsRune(u.Path, ';') { return fmt.Errorf("%w: ';' in the path", errCallbackPath) } // An '@' in the first segment is how an authority is smuggled past a // check that only looks at u.Host: "/x@evil/y" parses as a path, but // anything that later re-parses a concatenated form can read it as // userinfo. We build structurally and would survive it — this refuses it // anyway, because the next consumer of this field may not. if strings.Contains(u.Path, "@") { return fmt.Errorf("%w: '@' in the path", errCallbackPath) } // Traversal, on the DECODED path, which is the whole point: "%2e%2e" is // ".." to every server that will receive it. for _, seg := range strings.Split(u.Path, "/") { if seg == ".." { return fmt.Errorf("%w: '..' segment", errCallbackPath) } } return nil } // callbackTarget builds the URL the console will POST to: https, the // platform-assigned host, and the app's path — assembled as a url.URL and // never as a string. // // Structural assembly is the point (§6.2's last line). A concatenated // "https://" + host + path is one unvalidated path away from naming a // different host; a url.URL with Host set cannot express one no matter what // the path holds. It re-validates rather than trusting its caller because the // validator runs at the door and this runs at delivery, and those are far // enough apart that only one of them will be looked at. func callbackTarget(appHost, p string) (*url.URL, error) { if err := validCallbackPath(p); err != nil { return nil, err } u, err := url.Parse(p) if err != nil { return nil, fmt.Errorf("%w: %v", errCallbackPath, err) } out := &url.URL{ Scheme: "https", Host: appHost, Path: u.Path, RawQuery: u.RawQuery, } // THE ROUND TRIP. Everything above reasons about what url.URL will render; // this checks it. Re-parsing our own output and requiring the scheme, host // and port to be exactly what we set is the one assertion that does not // depend on having thought of the right hostile shape in advance — and the // review that prompted it found four we had not. back, err := url.Parse(out.String()) if err != nil { return nil, fmt.Errorf("%w: built a URL that will not re-parse: %v", errCallbackPath, err) } if back.Scheme != "https" || back.Host != appHost || back.User != nil { return nil, fmt.Errorf("%w: built target re-parses as %q, not a path on %q", errCallbackPath, back.String(), appHost) } return out, nil }