| 1 | package console |
| 2 | |
| 3 | import ( |
| 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. |
| 23 | func 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. |
| 100 | func 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. |
| 123 | func 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. |
| 135 | func 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 | |