Files
at-container-registry/pkg/appview/handlers/webhooks.go
T
Evan JarrettandClaude Opus 5 39919cc832 appview: stop webhooks reaching private addresses
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
2026-09-02 21:37:19 -05:00

383 lines
12 KiB
Go

package handlers
import (
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/appview/webhooks"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// webhookEntry is the template data for displaying a webhook
type webhookEntry struct {
ID string
Triggers int
URL string
HasSecret bool
CreatedAt string
// Computed fields from bitmask
HasPush bool
HasFirst bool
HasAll bool
HasChanged bool
HasQuota bool
Threshold int // quota threshold percent, 0 if HasQuota is false
}
type webhookLimits struct {
Max int
AllTriggers bool
PaidTierName string // Name of the first tier that enables all triggers
}
// WebhooksHandler returns the webhooks list partial via HTMX
type WebhooksHandler struct {
BaseUIHandler
}
func (h *WebhooksHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
h.renderWebhookError(w, "Authentication required")
return
}
webhookList, err := db.ListWebhooks(h.ReadOnlyDB, user.DID)
if err != nil {
slog.Warn("Failed to list webhooks", "error", err)
h.renderWebhookError(w, "Failed to load webhooks")
return
}
// Get tier limits from billing manager
limits := h.getWebhookLimits(user.DID)
h.renderWebhookList(w, webhookList, limits)
}
// AddWebhookHandler handles adding a new webhook via form POST
type AddWebhookHandler struct {
BaseUIHandler
}
func (h *AddWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
h.renderWebhookError(w, "Authentication required")
return
}
webhookURL := r.FormValue("url")
secret := r.FormValue("secret")
if webhookURL == "" {
h.renderWebhookError(w, "URL is required")
return
}
// Validate the destination: https only, and no non-public address. This is
// the friendly half of the SSRF guard; the half that actually holds is the
// dial-time check on the delivery client (pkg/appview/webhooks/ssrf.go),
// since an attacker controlling DNS can make a hostname that looks public
// here resolve to loopback at delivery.
if err := webhooks.ValidateWebhookURL(webhookURL); err != nil {
h.renderWebhookError(w, "Invalid webhook URL: "+err.Error())
return
}
// Parse trigger checkboxes
flags := 0
if r.FormValue("trigger_push") == "on" {
flags |= webhooks.TriggerPush
}
if r.FormValue("trigger_first") == "on" {
flags |= webhooks.TriggerFirst
}
if r.FormValue("trigger_all") == "on" {
flags |= webhooks.TriggerAll
}
if r.FormValue("trigger_changed") == "on" {
flags |= webhooks.TriggerChanged
}
thresholdPct := 0
if r.FormValue("trigger_quota") == "on" {
flags |= webhooks.TriggerQuota
rawThreshold := strings.TrimSpace(r.FormValue("quota_threshold"))
if rawThreshold == "" {
h.renderWebhookError(w, "Quota threshold is required when the quota trigger is enabled")
return
}
n, err := strconv.Atoi(rawThreshold)
if err != nil || n < 1 || n > 100 {
h.renderWebhookError(w, "Quota threshold must be a whole number between 1 and 100")
return
}
thresholdPct = n
}
if flags == 0 {
flags = webhooks.TriggerFirst // default
}
triggers := webhooks.PackTriggers(flags, thresholdPct)
// Tier enforcement
limits := h.getWebhookLimits(user.DID)
// Dedupe: refuse to add a second webhook with the same URL for this user.
// A duplicate is almost always an accidental double-submit and creates
// confusing behavior (same payload fires twice, separate delete buttons).
existing, err := db.ListWebhooks(h.ReadOnlyDB, user.DID)
if err != nil {
h.renderWebhookError(w, "Failed to check existing webhooks")
return
}
for _, ex := range existing {
if ex.URL == webhookURL {
h.renderWebhookError(w, "A webhook with this URL is already configured")
return
}
}
if limits.Max >= 0 && len(existing) >= limits.Max {
h.renderWebhookError(w, "Webhook limit reached")
return
}
// Trigger bitmask enforcement: free users can only set the free trigger
// flags. Check only the flag bits — the packed threshold percent (bits 8-15)
// must not be compared as a "trigger". Same mask the dispatch gate uses.
if !limits.AllTriggers && webhooks.TriggerFlags(triggers) & ^webhooks.FreeTriggerMask != 0 {
h.renderWebhookError(w, "Additional trigger types require a paid plan")
return
}
// Create webhook
webhook := &db.Webhook{
ID: uuid.New().String(),
UserDID: user.DID,
URL: webhookURL,
Secret: secret,
Triggers: triggers,
CreatedAt: time.Now(),
}
if err := db.InsertWebhook(h.DB, webhook); err != nil {
slog.Warn("Failed to insert webhook", "error", err)
h.renderWebhookError(w, "Failed to add webhook")
return
}
// Re-render the full list
h.refetchAndRender(w, user)
}
// DeleteWebhookHandler handles deleting a webhook
type DeleteWebhookHandler struct {
BaseUIHandler
}
func (h *DeleteWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
h.renderWebhookError(w, "Authentication required")
return
}
id := chi.URLParam(r, "id")
if id == "" {
h.renderWebhookError(w, "Missing webhook ID")
return
}
if err := db.DeleteWebhook(h.DB, id, user.DID); err != nil {
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "not owned") {
h.renderWebhookError(w, "Webhook not found")
} else {
h.renderWebhookError(w, "Failed to delete webhook")
}
return
}
// Re-render the full list
h.refetchAndRender(w, user)
}
// TestWebhookHandler sends a test payload
type TestWebhookHandler struct {
BaseUIHandler
}
func (h *TestWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
h.renderWebhookError(w, "Authentication required")
return
}
id := chi.URLParam(r, "id")
if id == "" {
h.renderWebhookError(w, "Missing webhook ID")
return
}
if h.WebhookDispatcher == nil {
h.renderAlert(w, "error", "Webhooks not configured")
return
}
success, err := h.WebhookDispatcher.DeliverTest(r.Context(), id, user.DID, user.Handle)
if err != nil {
h.renderAlert(w, "error", "Webhook not found or unauthorized")
return
}
if success {
h.renderAlert(w, "success", "Test webhook delivered successfully!")
} else {
h.renderAlert(w, "error", "Test delivery failed - check the webhook URL")
}
}
// ---- Shared helpers ----
// getWebhookLimits returns the webhook limits for a user based on their billing
// tier.
//
// This must agree with the delivery path, which wires
// BillingManager.GetWebhookLimits straight into the dispatcher (see
// NewDispatcher in server.go). An earlier version short-circuited on a disabled
// manager and answered "unlimited" without consulting it, so the UI offered
// unlimited webhooks while delivery silently capped non-captains at one and
// dropped every trigger outside FreeTriggerMask. Both paths now go through the
// same method so they cannot drift again.
//
// With billing compiled out the manager's stub is the policy: hold captains get
// unlimited, everyone else gets one webhook restricted to FreeTriggerMask
// (TriggerFirst | TriggerPush | TriggerQuota).
func (h *BaseUIHandler) getWebhookLimits(userDID string) webhookLimits {
if h.BillingManager == nil {
// No manager at all, which happens in tests and partially constructed
// handlers. Mirror the non-billing policy rather than inventing a third
// answer that neither path would agree with.
return webhookLimits{Max: 1}
}
limits := webhookLimits{}
limits.Max, limits.AllTriggers = h.BillingManager.GetWebhookLimits(userDID)
limits.PaidTierName = h.BillingManager.GetFirstTierWithAllTriggers()
return limits
}
func (h *BaseUIHandler) refetchAndRender(w http.ResponseWriter, user *db.User) {
webhookList, err := db.ListWebhooks(h.ReadOnlyDB, user.DID)
if err != nil {
h.renderWebhookError(w, "Failed to refresh webhook list")
return
}
limits := h.getWebhookLimits(user.DID)
h.renderWebhookList(w, webhookList, limits)
}
func (h *BaseUIHandler) renderWebhookList(w http.ResponseWriter, dbWebhooks []db.Webhook, limits webhookLimits) {
w.Header().Set("Content-Type", "text/html")
// Convert DB webhooks to template entries with computed trigger fields
entries := make([]webhookEntry, len(dbWebhooks))
for i, wh := range dbWebhooks {
flags := webhooks.TriggerFlags(wh.Triggers)
entries[i] = webhookEntry{
ID: wh.ID,
Triggers: wh.Triggers,
URL: wh.URL,
HasSecret: wh.HasSecret,
CreatedAt: wh.CreatedAt.Format(time.RFC3339),
HasPush: flags&webhooks.TriggerPush != 0,
HasFirst: flags&webhooks.TriggerFirst != 0,
HasAll: flags&webhooks.TriggerAll != 0,
HasChanged: flags&webhooks.TriggerChanged != 0,
HasQuota: flags&webhooks.TriggerQuota != 0,
Threshold: webhooks.ThresholdPct(wh.Triggers),
}
}
templateData := struct {
Webhooks []webhookEntry
Limits webhookLimits
ContainerID string
TriggerInfo []triggerInfo
}{
Webhooks: entries,
Limits: limits,
ContainerID: "webhooks-content",
TriggerInfo: webhookTriggerInfo(),
}
if err := h.Templates.ExecuteTemplate(w, "webhooks_list", templateData); err != nil {
slog.Error("Failed to render webhooks template", "error", err)
h.renderWebhookError(w, "Failed to render template")
}
}
type triggerInfo struct {
Name string
FormName string // form field name, e.g. "trigger_push" — set so templates don't need a ternary
Bit int
Label string
Description string
AlwaysAvailable bool // Available to free-tier users
DefaultChecked bool // Checked by default in the form
// NeedsThreshold is true for triggers that pair with a configurable
// percent threshold (currently just quota). The template renders an
// inline number input next to the checkbox; the handler parses
// ThresholdFormName into the quota_threshold field.
NeedsThreshold bool
ThresholdFormName string
ThresholdDefault int
}
// webhookTriggerInfo returns the canonical list of webhook trigger types.
// FormName is the HTML form field name (kept in sync with handler parsing).
func webhookTriggerInfo() []triggerInfo {
return []triggerInfo{
{Name: "push", FormName: "trigger_push", Bit: webhooks.TriggerPush, Label: "Image push", Description: "When an image is pushed to your repository", AlwaysAvailable: true},
{Name: "scan:first", FormName: "trigger_first", Bit: webhooks.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true},
{Name: "scan:all", FormName: "trigger_all", Bit: webhooks.TriggerAll, Label: "Every scan", Description: "On every scan completion"},
{Name: "scan:changed", FormName: "trigger_changed", Bit: webhooks.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"},
{
Name: "quota",
FormName: "trigger_quota",
Bit: webhooks.TriggerQuota,
Label: "Storage quota",
Description: "When your hold storage crosses the threshold percent. Fires once per crossing; re-arms when usage drops below.",
AlwaysAvailable: true,
NeedsThreshold: true,
ThresholdFormName: "quota_threshold",
ThresholdDefault: 90,
},
}
}
func (h *BaseUIHandler) renderWebhookError(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "text/html")
_ = h.Templates.ExecuteTemplate(w, "alert", map[string]string{
"Type": "error",
"Message": message,
})
}
func (h *BaseUIHandler) renderAlert(w http.ResponseWriter, alertType, message string) {
w.Header().Set("Content-Type", "text/html")
_ = h.Templates.ExecuteTemplate(w, "alert", map[string]string{
"Type": alertType,
"Message": message,
})
}