carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
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.
26const maxCallbackPath = 1024
27
28var 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.
42func 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.
152func 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