package console import ( "net/url" "strings" "testing" ) // THE HOSTILE TABLE, AND IT LANDS WITH THE DOOR THAT ACCEPTS THE FIELD. // // Draft 1 of the plan took the callback path in the enqueue task and validated // it four commits later, which means four commits during which the console // stored whatever an app sent. A validator that arrives after the field is a // validator that has already been bypassed — every item written in between is // still in the bucket when it lands. // // Spec §6.2. The reason this is a path and not a URL is that an app must not // choose the HOST: the console posts to the platform-assigned name and nowhere // else, because the alternative is an authenticated request generator pointed // at anything an app can name. Everything below is a way of smuggling a host, // a header, or a different resource past a field that is supposed to be a // path. func TestCallbackPathValidation(t *testing.T) { refused := []struct { name, path string }{ {"empty", ""}, {"not a path at all", "callback"}, {"absolute http URL", "http://evil.example/x"}, {"absolute https URL", "https://evil.example/x"}, {"scheme-only", "https:"}, {"protocol-relative", "//evil.example/x"}, {"protocol-relative, backslash", "/\\evil.example/x"}, {"backslash-backslash", "\\\\evil.example\\x"}, // url.Parse is happy to hand back an authority from this shape, and a // naive Scheme==""/Host=="" check can be walked past with it. {"authority via userinfo", "/x@evil.example/y"}, {"traversal", "/hooks/../../admin"}, {"traversal, trailing", "/hooks/.."}, {"traversal, encoded", "/hooks/%2e%2e/admin"}, {"raw CR", "/hooks\rX-Evil: 1"}, {"raw LF", "/hooks\nX-Evil: 1"}, {"raw CRLF", "/hooks\r\nX-Evil: 1"}, {"encoded CRLF", "/hooks%0d%0aX-Evil:%201"}, {"encoded CRLF, upper", "/hooks%0D%0AX-Evil:%201"}, {"fragment", "/hooks#frag"}, {"NUL", "/hooks\x00"}, {"over 1KB", "/" + strings.Repeat("a", 1024)}, // The DECODED forms of shapes already refused above. url.Parse // percent-decodes into u.Path, so a rule enforced only on the raw // string is a rule with a second door — the exact lesson AGENTS.md // now carries. Found by an external security review. {"encoded backslash", "/%5cevil.example/x"}, {"encoded double backslash", "/%5c%5cevil.example/x"}, {"encoded protocol-relative", "/%2f%2fevil.example/x"}, {"encoded single extra slash", "/%2fevil.example/x"}, // A servlet-style receiver strips ;params BEFORE normalising the // path, which turns this into /admin. {"path parameter hiding a traversal", "/safe/..;ignored/admin"}, {"bare path parameter", "/hooks;jsessionid=1/scan"}, // Fullwidth full stops. A receiver applying Unicode compatibility // normalisation turns these into "..". {"fullwidth traversal", "/\uff0e\uff0e/admin"}, {"encoded fullwidth traversal", "/%EF%BC%8E%EF%BC%8E/admin"}, } for _, tc := range refused { t.Run("refused: "+tc.name, func(t *testing.T) { if err := validCallbackPath(tc.path); err == nil { t.Fatalf("validCallbackPath(%q) = nil, want a refusal", tc.path) } }) } accepted := []struct { name, path string }{ {"plain", "/hooks/scan"}, {"root", "/"}, {"with a query", "/hooks/scan?token=abc"}, {"query with an encoded value", "/hooks/scan?k=a%20b"}, {"dots that are not traversal", "/hooks/v1.2/scan"}, {"a dotted segment that is not ..", "/hooks/.well-known/scan"}, {"exactly 1KB", "/" + strings.Repeat("a", 1022)}, } for _, tc := range accepted { t.Run("accepted: "+tc.name, func(t *testing.T) { if err := validCallbackPath(tc.path); err != nil { t.Fatalf("validCallbackPath(%q) = %v, want it accepted", tc.path, err) } }) } } // The target is BUILT, never concatenated — §6.2's last line, which exists // because concatenation is how a validated path still ends up naming another // host. url.URL{} with the host fixed cannot express a different one no matter // what the path holds. func TestCallbackTargetIsBuiltStructurally(t *testing.T) { const host = "titogo.bab.oncarlos.com" got, err := callbackTarget(host, "/hooks/scan?a=b%20c") if err != nil { t.Fatal(err) } want := "https://titogo.bab.oncarlos.com/hooks/scan?a=b%20c" if got.String() != want { t.Fatalf("target = %q, want %q", got, want) } if got.Scheme != "https" { t.Fatalf("scheme = %q — a callback carries a signature and a verdict; plaintext is not an option", got.Scheme) } if got.Host != host { t.Fatalf("host = %q, want the platform-assigned %q", got.Host, host) } } // And the validator is the only way in. A path that got past validation must // still not be able to move the host, so the builder refuses too rather than // trusting its caller — the two are used from different places (the door // stores, the drain delivers) and only one of them will be looking. func TestCallbackTargetRefusesWhatTheValidatorRefuses(t *testing.T) { for _, p := range []string{"https://evil.example/x", "//evil.example/x", "/x\r\nX: 1"} { if _, err := callbackTarget("titogo.bab.oncarlos.com", p); err == nil { t.Fatalf("callbackTarget(%q) built a target, want a refusal", p) } } } // url.Parse's own view, pinned. Spec §6.2 states the post-parse invariant as // Scheme=="" Host=="" User==nil Opaque=="" — this asserts our validator agrees // with it on every accepted path, so a future rewrite of the checks cannot // quietly admit a shape the spec rules out. func TestAcceptedPathsCarryNoAuthority(t *testing.T) { for _, p := range []string{"/hooks/scan", "/", "/hooks/scan?token=abc", "/hooks/v1.2/scan"} { u, err := url.Parse(p) if err != nil { t.Fatalf("url.Parse(%q): %v", p, err) } if u.Scheme != "" || u.Host != "" || u.User != nil || u.Opaque != "" { t.Fatalf("accepted path %q parses with an authority: %+v", p, u) } } }