| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "net/url" |
| 7 | "strings" |
| 8 | ) |
| 9 | |
| 10 | // The callback path: what an app may ask the console to POST to, and why it is |
| 11 | // a path rather than a URL. |
| 12 | // |
| 13 | // The console is an authenticated request generator. It holds credentials no |
| 14 | // tenant has, it sits inside the deployment's network, and it will make an |
| 15 | // outbound request because an app asked it to. If an app could name the HOST, |
| 16 | // that is the whole of SSRF handed over in a JSON field — so it names a path, |
| 17 | // and the console supplies the host from the app's platform-assigned name. |
| 18 | // |
| 19 | // Everything refused below is a way of smuggling a host, a header, or a |
| 20 | // different resource through a field that is supposed to be a path. Spec §6.2. |
| 21 | |
| 22 | // maxCallbackPath is 1KB. Not a security boundary on its own — every hostile |
| 23 | // shape below is refused at any length — but a durable field an app controls |
| 24 | // wants a bound, and a callback path longer than a kilobyte is a mistake |
| 25 | // rather than a requirement. |
| 26 | const maxCallbackPath = 1024 |
| 27 | |
| 28 | var errCallbackPath = errors.New("console: callback path") |
| 29 | |
| 30 | // validCallbackPath is the rule. It runs at the DOOR, on the way in, so that |
| 31 | // nothing hostile is ever stored — a validator that runs at delivery time |
| 32 | // leaves every already-accepted item in the bucket to be found later. |
| 33 | // |
| 34 | // IT CHECKS BOTH SPELLINGS OF EVERY RULE: the raw string the app sent AND the |
| 35 | // decoded path url.Parse produces. The first version of this function checked |
| 36 | // only the raw string, and an external security review walked straight past it |
| 37 | // with "/%5c%5cevil/x" and "/%2f%2fevil/x" — percent-encoded forms of two |
| 38 | // shapes it explicitly refused in plaintext. That is AGENTS.md's own rule |
| 39 | // turned on us: a rule stated as an absence can only be checked by looking for |
| 40 | // the absent thing, and cannot see a second door. url.Parse IS the second |
| 41 | // door. |
| 42 | func validCallbackPath(p string) error { |
| 43 | switch { |
| 44 | case p == "": |
| 45 | return fmt.Errorf("%w: empty", errCallbackPath) |
| 46 | case len(p) > maxCallbackPath: |
| 47 | return fmt.Errorf("%w: longer than %d bytes", errCallbackPath, maxCallbackPath) |
| 48 | } |
| 49 | |
| 50 | // ASCII ONLY, and this is the rule that closes the normalisation family. |
| 51 | // A receiver that applies Unicode compatibility normalisation turns |
| 52 | // U+FF0E FULLWIDTH FULL STOP into ".", so "/../admin" becomes a |
| 53 | // traversal on arrival while looking like nothing here. We cannot know |
| 54 | // what the receiving stack normalises, and the set of confusables is not |
| 55 | // something to enumerate — so a callback path is printable ASCII, and an |
| 56 | // app that wants anything else has to say it in the query string, where |
| 57 | // it stays percent-encoded and is never a path segment. |
| 58 | for i := 0; i < len(p); i++ { |
| 59 | if c := p[i]; c < 0x20 || c > 0x7e { |
| 60 | return fmt.Errorf("%w: byte %#02x at %d is not printable ASCII", errCallbackPath, c, i) |
| 61 | } |
| 62 | } |
| 63 | // Percent-encoded control bytes, which survive to whatever decodes them. |
| 64 | // Case-insensitive, because %0D and %0d are the same byte. |
| 65 | lower := strings.ToLower(p) |
| 66 | for _, enc := range []string{"%0d", "%0a", "%00"} { |
| 67 | if strings.Contains(lower, enc) { |
| 68 | return fmt.Errorf("%w: encoded control byte %s", errCallbackPath, enc) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | // The raw shape. "//evil/x" is protocol-relative and "/\evil/x" is treated |
| 73 | // as protocol-relative by enough parsers to count. |
| 74 | switch { |
| 75 | case !strings.HasPrefix(p, "/"): |
| 76 | return fmt.Errorf("%w: must begin with /", errCallbackPath) |
| 77 | case strings.HasPrefix(p, "//"): |
| 78 | return fmt.Errorf("%w: protocol-relative", errCallbackPath) |
| 79 | case strings.ContainsRune(p, '\\'): |
| 80 | return fmt.Errorf("%w: backslash", errCallbackPath) |
| 81 | case strings.Contains(p, "#"): |
| 82 | return fmt.Errorf("%w: fragment", errCallbackPath) |
| 83 | } |
| 84 | |
| 85 | u, err := url.Parse(p) |
| 86 | if err != nil { |
| 87 | return fmt.Errorf("%w: %v", errCallbackPath, err) |
| 88 | } |
| 89 | // The spec's post-parse invariant, stated as an invariant. If any of these |
| 90 | // is non-empty the string named something other than a path on this host, |
| 91 | // whatever route it took to get there. |
| 92 | if u.Scheme != "" || u.Host != "" || u.User != nil || u.Opaque != "" { |
| 93 | return fmt.Errorf("%w: names a host or scheme", errCallbackPath) |
| 94 | } |
| 95 | |
| 96 | // NOW THE SAME RULES AGAIN, on the decoded path. Everything below is a |
| 97 | // shape that is refused above in plaintext and was reachable through |
| 98 | // percent-encoding until an external review pointed it out. |
| 99 | switch { |
| 100 | case !strings.HasPrefix(u.Path, "/"): |
| 101 | return fmt.Errorf("%w: decodes to something that is not a path", errCallbackPath) |
| 102 | case strings.HasPrefix(u.Path, "//"): |
| 103 | return fmt.Errorf("%w: decodes to a protocol-relative path", errCallbackPath) |
| 104 | case strings.ContainsRune(u.Path, '\\'): |
| 105 | return fmt.Errorf("%w: decodes to a path containing a backslash", errCallbackPath) |
| 106 | case strings.ContainsAny(u.Path, "#?"): |
| 107 | return fmt.Errorf("%w: decodes to a path containing a delimiter", errCallbackPath) |
| 108 | } |
| 109 | for i := 0; i < len(u.Path); i++ { |
| 110 | if c := u.Path[i]; c < 0x21 || c > 0x7e { |
| 111 | return fmt.Errorf("%w: decoded path byte %#02x at %d is not printable ASCII", |
| 112 | errCallbackPath, c, i) |
| 113 | } |
| 114 | } |
| 115 | // SEMICOLONS. A servlet-style receiver strips ";params" from each segment |
| 116 | // BEFORE it normalises the path, so "/safe/..;ignored/admin" arrives as |
| 117 | // "/safe/../admin" and traverses — while every check that splits on "/" |
| 118 | // and compares to ".." sees the harmless segment "..;ignored". Refused |
| 119 | // outright: a callback path has no legitimate use for one. |
| 120 | if strings.ContainsRune(u.Path, ';') { |
| 121 | return fmt.Errorf("%w: ';' in the path", errCallbackPath) |
| 122 | } |
| 123 | // An '@' in the first segment is how an authority is smuggled past a |
| 124 | // check that only looks at u.Host: "/x@evil/y" parses as a path, but |
| 125 | // anything that later re-parses a concatenated form can read it as |
| 126 | // userinfo. We build structurally and would survive it — this refuses it |
| 127 | // anyway, because the next consumer of this field may not. |
| 128 | if strings.Contains(u.Path, "@") { |
| 129 | return fmt.Errorf("%w: '@' in the path", errCallbackPath) |
| 130 | } |
| 131 | |
| 132 | // Traversal, on the DECODED path, which is the whole point: "%2e%2e" is |
| 133 | // ".." to every server that will receive it. |
| 134 | for _, seg := range strings.Split(u.Path, "/") { |
| 135 | if seg == ".." { |
| 136 | return fmt.Errorf("%w: '..' segment", errCallbackPath) |
| 137 | } |
| 138 | } |
| 139 | return nil |
| 140 | } |
| 141 | |
| 142 | // callbackTarget builds the URL the console will POST to: https, the |
| 143 | // platform-assigned host, and the app's path — assembled as a url.URL and |
| 144 | // never as a string. |
| 145 | // |
| 146 | // Structural assembly is the point (§6.2's last line). A concatenated |
| 147 | // "https://" + host + path is one unvalidated path away from naming a |
| 148 | // different host; a url.URL with Host set cannot express one no matter what |
| 149 | // the path holds. It re-validates rather than trusting its caller because the |
| 150 | // validator runs at the door and this runs at delivery, and those are far |
| 151 | // enough apart that only one of them will be looked at. |
| 152 | func callbackTarget(appHost, p string) (*url.URL, error) { |
| 153 | if err := validCallbackPath(p); err != nil { |
| 154 | return nil, err |
| 155 | } |
| 156 | u, err := url.Parse(p) |
| 157 | if err != nil { |
| 158 | return nil, fmt.Errorf("%w: %v", errCallbackPath, err) |
| 159 | } |
| 160 | out := &url.URL{ |
| 161 | Scheme: "https", |
| 162 | Host: appHost, |
| 163 | Path: u.Path, |
| 164 | RawQuery: u.RawQuery, |
| 165 | } |
| 166 | // THE ROUND TRIP. Everything above reasons about what url.URL will render; |
| 167 | // this checks it. Re-parsing our own output and requiring the scheme, host |
| 168 | // and port to be exactly what we set is the one assertion that does not |
| 169 | // depend on having thought of the right hostile shape in advance — and the |
| 170 | // review that prompted it found four we had not. |
| 171 | back, err := url.Parse(out.String()) |
| 172 | if err != nil { |
| 173 | return nil, fmt.Errorf("%w: built a URL that will not re-parse: %v", errCallbackPath, err) |
| 174 | } |
| 175 | if back.Scheme != "https" || back.Host != appHost || back.User != nil { |
| 176 | return nil, fmt.Errorf("%w: built target re-parses as %q, not a path on %q", |
| 177 | errCallbackPath, back.String(), appHost) |
| 178 | } |
| 179 | return out, nil |
| 180 | } |
| 181 | |