mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-26 12:14:17 +00:00
The URL check accepted http:// while telling the user "must be https", and guarded no addresses at all. POST /api/webhooks with http://127.0.0.1:9/hook returned 200 and created the webhook, so both scheduled deliveries and the synchronous Test button would dial arbitrary destinations from the appview host, on demand, for any authenticated user. Loopback, link-local (including the cloud metadata endpoint at 169.254.169.254) and RFC1918 were all reachable. Enforces https, and refuses non-public destinations. The load-bearing half is the dial-time check, not the creation-time one. An attacker controls their own DNS, so a hostname that resolves publicly when the webhook is created can resolve to loopback when it is delivered, and a creation-time check cannot see a redirect either. The guard is therefore a net.Dialer Control hook on the delivery client, which inspects the resolved address on every connection attempt. Transport.Proxy is explicitly nil: honouring HTTP(S)_PROXY would route around the Control hook and hand the bypass straight back. Redirects are re-validated per hop and capped at 3. The creation-time check stays so the user gets an immediate, comprehensible error instead of a silent delivery failure later. IPv4-mapped IPv6 is unmapped before every check, so ::ffff:127.0.0.1 and friends hit the IPv4 rules. Ranges with no net.IP helper are listed explicitly: CGNAT, NAT64, ::/96, TEST-NET and reserved space. Both outbound paths are covered, since the scheduled dispatcher and the Test button both funnel through attemptDelivery. The dispatcher's other client is deliberately left unguarded: it fetches quota stats from holds, which legitimately live on private addresses, and those URLs are not user-supplied. Note this removes the ability to point a webhook at a localhost receiver in local development. There is deliberately no environment-variable escape hatch, since a security toggle read from the environment is the same bypass wearing a nicer coat. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
126 lines
4.1 KiB
Go
126 lines
4.1 KiB
Go
package handlers
|
|
|
|
import (
|
|
"html/template"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"atcr.io/pkg/appview/db"
|
|
"atcr.io/pkg/appview/middleware"
|
|
)
|
|
|
|
// Finding 35: the creation-time check said "must be https" but accepted
|
|
// http://, so POST /api/webhooks with http://127.0.0.1:9/hook returned 200 and
|
|
// created a webhook the appview would then dial on demand.
|
|
|
|
// webhookTestTemplates provides the two templates the webhook handlers render.
|
|
// The bodies only need to be identifiable, not faithful.
|
|
func webhookTestTemplates(t *testing.T) *template.Template {
|
|
t.Helper()
|
|
tmpl := template.Must(template.New("alert").Parse(`ALERT:{{ .Message }}`))
|
|
template.Must(tmpl.New("webhooks_list").Parse(
|
|
`LIST:{{ range .Webhooks }}{{ .URL }};{{ end }}`))
|
|
return tmpl
|
|
}
|
|
|
|
func postWebhook(t *testing.T, h *AddWebhookHandler, webhookURL string) string {
|
|
t.Helper()
|
|
form := url.Values{}
|
|
form.Set("url", webhookURL)
|
|
form.Set("trigger_push", "on")
|
|
|
|
req := httptest.NewRequest("POST", "/api/webhooks", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req = middleware.WithUser(req, &db.User{DID: "did:plc:webhooktester", Handle: "wh.test"})
|
|
|
|
rr := httptest.NewRecorder()
|
|
h.ServeHTTP(rr, req)
|
|
return rr.Body.String()
|
|
}
|
|
|
|
func TestAddWebhook_RejectsNonHTTPSAndPrivateDestinations(t *testing.T) {
|
|
database := setupTestDB(t)
|
|
defer database.Close()
|
|
|
|
h := &AddWebhookHandler{BaseUIHandler: BaseUIHandler{
|
|
Templates: webhookTestTemplates(t),
|
|
DB: database,
|
|
ReadOnlyDB: database,
|
|
}}
|
|
|
|
tests := []struct {
|
|
name string
|
|
url string
|
|
wantMsg string
|
|
}{
|
|
{"plain http loopback", "http://127.0.0.1:9/hook", "must be https"},
|
|
{"plain http public host", "http://example.com/hook", "must be https"},
|
|
{"no scheme", "example.com/hook", "must be https"},
|
|
{"file scheme", "file:///etc/passwd", "must be https"},
|
|
{"https loopback", "https://127.0.0.1:9/hook", "private or loopback"},
|
|
{"https loopback v6", "https://[::1]/hook", "private or loopback"},
|
|
{"https rfc1918", "https://10.0.0.1/hook", "private or loopback"},
|
|
{"https link-local metadata", "https://169.254.169.254/latest/meta-data/", "private or loopback"},
|
|
{"https ipv4-mapped loopback", "https://[::ffff:127.0.0.1]/hook", "private or loopback"},
|
|
{"https localhost name", "https://localhost:9/hook", "private or loopback"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
body := postWebhook(t, h, tt.url)
|
|
if !strings.HasPrefix(body, "ALERT:") {
|
|
t.Fatalf("expected an error alert, got %q", body)
|
|
}
|
|
if !strings.Contains(body, tt.wantMsg) {
|
|
t.Errorf("error message %q does not mention %q", body, tt.wantMsg)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Nothing above may have been stored.
|
|
stored, err := db.ListWebhooks(database, "did:plc:webhooktester")
|
|
if err != nil {
|
|
t.Fatalf("list webhooks: %v", err)
|
|
}
|
|
if len(stored) != 0 {
|
|
t.Fatalf("rejected URLs were persisted anyway: %+v", stored)
|
|
}
|
|
}
|
|
|
|
func TestAddWebhook_AcceptsPublicHTTPS(t *testing.T) {
|
|
database := setupTestDB(t)
|
|
defer database.Close()
|
|
|
|
h := &AddWebhookHandler{BaseUIHandler: BaseUIHandler{
|
|
Templates: webhookTestTemplates(t),
|
|
DB: database,
|
|
ReadOnlyDB: database,
|
|
}}
|
|
|
|
// The webhooks table has a FK on the owning user.
|
|
if err := db.InsertUserIfNotExists(database, &db.User{DID: "did:plc:webhooktester", Handle: "wh.test"}); err != nil {
|
|
t.Fatalf("seed user: %v", err)
|
|
}
|
|
|
|
const good = "https://hooks.example.com/services/abc"
|
|
body := postWebhook(t, h, good)
|
|
if !strings.HasPrefix(body, "LIST:") {
|
|
t.Fatalf("expected the webhook list to render, got %q", body)
|
|
}
|
|
// ListWebhooks masks the path for display, so match on the host prefix.
|
|
const displayPrefix = "https://hooks.example.com/"
|
|
if !strings.Contains(body, displayPrefix) {
|
|
t.Errorf("rendered list %q does not contain the new webhook", body)
|
|
}
|
|
|
|
stored, err := db.ListWebhooks(database, "did:plc:webhooktester")
|
|
if err != nil {
|
|
t.Fatalf("list webhooks: %v", err)
|
|
}
|
|
if len(stored) != 1 || !strings.HasPrefix(stored[0].URL, displayPrefix) {
|
|
t.Fatalf("expected the public https webhook to be stored, got %+v", stored)
|
|
}
|
|
}
|