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 "net/url"
5 "strings"
6 "testing"
7)
8
9// THE HOSTILE TABLE, AND IT LANDS WITH THE DOOR THAT ACCEPTS THE FIELD.
10//
11// Draft 1 of the plan took the callback path in the enqueue task and validated
12// it four commits later, which means four commits during which the console
13// stored whatever an app sent. A validator that arrives after the field is a
14// validator that has already been bypassed — every item written in between is
15// still in the bucket when it lands.
16//
17// Spec §6.2. The reason this is a path and not a URL is that an app must not
18// choose the HOST: the console posts to the platform-assigned name and nowhere
19// else, because the alternative is an authenticated request generator pointed
20// at anything an app can name. Everything below is a way of smuggling a host,
21// a header, or a different resource past a field that is supposed to be a
22// path.
23func TestCallbackPathValidation(t *testing.T) {
24 refused := []struct {
25 name, path string
26 }{
27 {"empty", ""},
28 {"not a path at all", "callback"},
29 {"absolute http URL", "http://evil.example/x"},
30 {"absolute https URL", "https://evil.example/x"},
31 {"scheme-only", "https:"},
32 {"protocol-relative", "//evil.example/x"},
33 {"protocol-relative, backslash", "/\\evil.example/x"},
34 {"backslash-backslash", "\\\\evil.example\\x"},
35 // url.Parse is happy to hand back an authority from this shape, and a
36 // naive Scheme==""/Host=="" check can be walked past with it.
37 {"authority via userinfo", "/x@evil.example/y"},
38 {"traversal", "/hooks/../../admin"},
39 {"traversal, trailing", "/hooks/.."},
40 {"traversal, encoded", "/hooks/%2e%2e/admin"},
41 {"raw CR", "/hooks\rX-Evil: 1"},
42 {"raw LF", "/hooks\nX-Evil: 1"},
43 {"raw CRLF", "/hooks\r\nX-Evil: 1"},
44 {"encoded CRLF", "/hooks%0d%0aX-Evil:%201"},
45 {"encoded CRLF, upper", "/hooks%0D%0AX-Evil:%201"},
46 {"fragment", "/hooks#frag"},
47 {"NUL", "/hooks\x00"},
48 {"over 1KB", "/" + strings.Repeat("a", 1024)},
49 // The DECODED forms of shapes already refused above. url.Parse
50 // percent-decodes into u.Path, so a rule enforced only on the raw
51 // string is a rule with a second door — the exact lesson AGENTS.md
52 // now carries. Found by an external security review.
53 {"encoded backslash", "/%5cevil.example/x"},
54 {"encoded double backslash", "/%5c%5cevil.example/x"},
55 {"encoded protocol-relative", "/%2f%2fevil.example/x"},
56 {"encoded single extra slash", "/%2fevil.example/x"},
57 // A servlet-style receiver strips ;params BEFORE normalising the
58 // path, which turns this into /admin.
59 {"path parameter hiding a traversal", "/safe/..;ignored/admin"},
60 {"bare path parameter", "/hooks;jsessionid=1/scan"},
61 // Fullwidth full stops. A receiver applying Unicode compatibility
62 // normalisation turns these into "..".
63 {"fullwidth traversal", "/\uff0e\uff0e/admin"},
64 {"encoded fullwidth traversal", "/%EF%BC%8E%EF%BC%8E/admin"},
65 }
66
67 for _, tc := range refused {
68 t.Run("refused: "+tc.name, func(t *testing.T) {
69 if err := validCallbackPath(tc.path); err == nil {
70 t.Fatalf("validCallbackPath(%q) = nil, want a refusal", tc.path)
71 }
72 })
73 }
74
75 accepted := []struct {
76 name, path string
77 }{
78 {"plain", "/hooks/scan"},
79 {"root", "/"},
80 {"with a query", "/hooks/scan?token=abc"},
81 {"query with an encoded value", "/hooks/scan?k=a%20b"},
82 {"dots that are not traversal", "/hooks/v1.2/scan"},
83 {"a dotted segment that is not ..", "/hooks/.well-known/scan"},
84 {"exactly 1KB", "/" + strings.Repeat("a", 1022)},
85 }
86
87 for _, tc := range accepted {
88 t.Run("accepted: "+tc.name, func(t *testing.T) {
89 if err := validCallbackPath(tc.path); err != nil {
90 t.Fatalf("validCallbackPath(%q) = %v, want it accepted", tc.path, err)
91 }
92 })
93 }
94}
95
96// The target is BUILT, never concatenated — §6.2's last line, which exists
97// because concatenation is how a validated path still ends up naming another
98// host. url.URL{} with the host fixed cannot express a different one no matter
99// what the path holds.
100func TestCallbackTargetIsBuiltStructurally(t *testing.T) {
101 const host = "titogo.bab.oncarlos.com"
102
103 got, err := callbackTarget(host, "/hooks/scan?a=b%20c")
104 if err != nil {
105 t.Fatal(err)
106 }
107 want := "https://titogo.bab.oncarlos.com/hooks/scan?a=b%20c"
108 if got.String() != want {
109 t.Fatalf("target = %q, want %q", got, want)
110 }
111 if got.Scheme != "https" {
112 t.Fatalf("scheme = %q — a callback carries a signature and a verdict; plaintext is not an option", got.Scheme)
113 }
114 if got.Host != host {
115 t.Fatalf("host = %q, want the platform-assigned %q", got.Host, host)
116 }
117}
118
119// And the validator is the only way in. A path that got past validation must
120// still not be able to move the host, so the builder refuses too rather than
121// trusting its caller — the two are used from different places (the door
122// stores, the drain delivers) and only one of them will be looking.
123func TestCallbackTargetRefusesWhatTheValidatorRefuses(t *testing.T) {
124 for _, p := range []string{"https://evil.example/x", "//evil.example/x", "/x\r\nX: 1"} {
125 if _, err := callbackTarget("titogo.bab.oncarlos.com", p); err == nil {
126 t.Fatalf("callbackTarget(%q) built a target, want a refusal", p)
127 }
128 }
129}
130
131// url.Parse's own view, pinned. Spec §6.2 states the post-parse invariant as
132// Scheme=="" Host=="" User==nil Opaque=="" — this asserts our validator agrees
133// with it on every accepted path, so a future rewrite of the checks cannot
134// quietly admit a shape the spec rules out.
135func TestAcceptedPathsCarryNoAuthority(t *testing.T) {
136 for _, p := range []string{"/hooks/scan", "/", "/hooks/scan?token=abc", "/hooks/v1.2/scan"} {
137 u, err := url.Parse(p)
138 if err != nil {
139 t.Fatalf("url.Parse(%q): %v", p, err)
140 }
141 if u.Scheme != "" || u.Host != "" || u.User != nil || u.Opaque != "" {
142 t.Fatalf("accepted path %q parses with an authority: %+v", p, u)
143 }
144 }
145}
146