Files
pad/internal/store/webhooks.go
T
xarmian 8e16501e8a fix(security): scope webhook + token mutations by workspace to close cross-workspace IDOR (TASK-266) (#936)
* fix(security): scope webhook + token mutations by workspace to close cross-workspace IDOR (TASK-266)

handleDeleteWebhook, handleTestWebhook, and handleDeleteToken looked their
object up by ID with no workspace-ownership predicate. requireMinRole("owner")
only proves the caller owns the URL's workspace — not that the {webhookID} /
{tokenID} belongs to it — so any owner of any workspace could delete or test
another workspace's webhook, or revoke its API token, given the object ID
(cross-workspace IDOR / integrity + DoS + existence oracle).

Fix, matching the existing pre-fetch-and-compare idiom used by
handleDeleteWorkspaceAttachment / views / comments / links:
- webhooks: pre-fetch via GetWebhook and 404 unless hook.WorkspaceID matches
  the URL workspace (delete + test paths).
- tokens: new store.DeleteAPITokenScoped(id, workspaceID) doing
  DELETE ... WHERE id = ? AND workspace_id = ?, mirroring DeleteUserAPIToken.
  The unscoped DeleteAPIToken (its only caller) is removed.

Adds TestWebhookTokenCrossWorkspaceIDOR: an owner of workspace B cannot
delete/test A's webhook or revoke A's token via B's URL (404, objects survive),
while the legitimate owner still can within their own workspace. Verified
red-before/green-after.

Part of PLAN-259 (pre-open-source security audit). Closes the webhook+token
half of TASK-266; views/comments/links were already scoped.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra

* fix(security): atomic workspace-scoped webhook delete per Codex review (round 1)

handleDeleteWebhook pre-fetched via GetWebhook, which decrypts the HMAC
secret. A rotated/missing encryption key or corrupted ciphertext would make
the delete return 500, leaving a broken webhook undeletable. Replace the
pre-fetch-and-compare with an atomic store.DeleteWebhookScoped(id, workspaceID)
(DELETE ... WHERE id = ? AND workspace_id = ?) — same idiom as the token fix,
no decrypt on the delete path. The unscoped DeleteWebhook (its only caller) is
removed. handleTestWebhook keeps GetWebhook since it needs the decrypted hook
to dispatch.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra

* fix(security): scope test-webhook lookup before decrypt per Codex review (round 2)

handleTestWebhook fetched via GetWebhook (SELECT + decrypt by ID) and only then
compared workspace, so an undecryptable foreign webhook returned 500 rather than
404 — a residual cross-workspace existence oracle. Add store.GetWebhookScoped(id,
workspaceID) which applies the workspace_id predicate in SQL before decrypting;
a foreign/missing ID returns (nil,nil) → 404 without touching the ciphertext.
Strengthen the regression test's victim webhook with a non-empty secret so the
scoped-before-decrypt path is exercised.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-14 23:24:55 -04:00

201 lines
6.5 KiB
Go

package store
import (
"database/sql"
"fmt"
"github.com/PerpetualSoftware/pad/internal/models"
)
// CreateWebhook registers a new webhook for a workspace.
func (s *Store) CreateWebhook(workspaceID string, input models.WebhookCreate) (*models.Webhook, error) {
id := newID()
ts := now()
evts := input.Events
if evts == "" {
evts = `["*"]`
}
// Encrypt the HMAC secret at rest. With no encryption key configured
// (common on self-host) encrypt() returns the plaintext unchanged, so
// this stays a no-op fallback rather than a hard requirement.
encSecret, err := s.encrypt(input.Secret)
if err != nil {
return nil, fmt.Errorf("encrypt webhook secret: %w", err)
}
_, err = s.db.Exec(s.q(`
INSERT INTO webhooks (id, workspace_id, url, secret, events, active, created_at, updated_at, failure_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
`), id, workspaceID, input.URL, encSecret, evts, s.dialect.BoolToInt(true), ts, ts)
if err != nil {
return nil, fmt.Errorf("insert webhook: %w", err)
}
return s.GetWebhook(id)
}
// GetWebhook retrieves a single webhook by ID.
func (s *Store) GetWebhook(id string) (*models.Webhook, error) {
var wh models.Webhook
var active bool
var createdAt, updatedAt string
var lastTriggeredAt *string
err := s.db.QueryRow(s.q(`
SELECT id, workspace_id, url, secret, events, active, created_at, updated_at, last_triggered_at, failure_count
FROM webhooks
WHERE id = ?
`), id).Scan(
&wh.ID, &wh.WorkspaceID, &wh.URL, &wh.Secret, &wh.Events,
&active, &createdAt, &updatedAt, &lastTriggeredAt, &wh.FailureCount,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get webhook: %w", err)
}
// Decrypt the secret for internal use (the dispatcher signs with the
// plaintext). Pre-encryption rows lack the "enc:" prefix and pass through
// unchanged, so existing plaintext secrets keep working.
if wh.Secret, err = s.decrypt(wh.Secret); err != nil {
return nil, fmt.Errorf("decrypt webhook secret: %w", err)
}
wh.HasSecret = wh.Secret != ""
wh.Active = active
wh.CreatedAt = parseTime(createdAt)
wh.UpdatedAt = parseTime(updatedAt)
wh.LastTriggeredAt = parseTimePtr(lastTriggeredAt)
return &wh, nil
}
// GetWebhookScoped retrieves a single webhook by ID, but only if it belongs to
// the given workspace. The workspace_id predicate is applied in SQL BEFORE the
// secret is decrypted, so a foreign (or nonexistent) ID returns (nil, nil)
// without touching the ciphertext — this both closes the cross-workspace
// existence oracle (TASK-266) and avoids a decrypt-failure 500 leaking that a
// foreign webhook exists. Returns (nil, nil) when no webhook matches both.
func (s *Store) GetWebhookScoped(id, workspaceID string) (*models.Webhook, error) {
var wh models.Webhook
var active bool
var createdAt, updatedAt string
var lastTriggeredAt *string
err := s.db.QueryRow(s.q(`
SELECT id, workspace_id, url, secret, events, active, created_at, updated_at, last_triggered_at, failure_count
FROM webhooks
WHERE id = ? AND workspace_id = ?
`), id, workspaceID).Scan(
&wh.ID, &wh.WorkspaceID, &wh.URL, &wh.Secret, &wh.Events,
&active, &createdAt, &updatedAt, &lastTriggeredAt, &wh.FailureCount,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get webhook: %w", err)
}
if wh.Secret, err = s.decrypt(wh.Secret); err != nil {
return nil, fmt.Errorf("decrypt webhook secret: %w", err)
}
wh.HasSecret = wh.Secret != ""
wh.Active = active
wh.CreatedAt = parseTime(createdAt)
wh.UpdatedAt = parseTime(updatedAt)
wh.LastTriggeredAt = parseTimePtr(lastTriggeredAt)
return &wh, nil
}
// ListWebhooks returns all webhooks for a workspace.
func (s *Store) ListWebhooks(workspaceID string) ([]models.Webhook, error) {
rows, err := s.db.Query(s.q(`
SELECT id, workspace_id, url, secret, events, active, created_at, updated_at, last_triggered_at, failure_count
FROM webhooks
WHERE workspace_id = ?
ORDER BY created_at ASC
`), workspaceID)
if err != nil {
return nil, fmt.Errorf("list webhooks: %w", err)
}
defer rows.Close()
var result []models.Webhook
for rows.Next() {
var wh models.Webhook
var active bool
var createdAt, updatedAt string
var lastTriggeredAt *string
if err := rows.Scan(
&wh.ID, &wh.WorkspaceID, &wh.URL, &wh.Secret, &wh.Events,
&active, &createdAt, &updatedAt, &lastTriggeredAt, &wh.FailureCount,
); err != nil {
return nil, fmt.Errorf("scan webhook: %w", err)
}
if wh.Secret, err = s.decrypt(wh.Secret); err != nil {
return nil, fmt.Errorf("decrypt webhook secret: %w", err)
}
wh.HasSecret = wh.Secret != ""
wh.Active = active
wh.CreatedAt = parseTime(createdAt)
wh.UpdatedAt = parseTime(updatedAt)
wh.LastTriggeredAt = parseTimePtr(lastTriggeredAt)
result = append(result, wh)
}
return result, rows.Err()
}
// DeleteWebhookScoped removes a webhook by ID, verifying it belongs to the given
// workspace. Prevents cross-workspace deletion (TASK-266): an owner of one
// workspace must not be able to delete another workspace's webhook by ID. The
// scoped DELETE is also atomic and avoids decrypting the secret just to delete —
// a rotated/missing encryption key would otherwise leave a broken webhook
// undeletable. Returns sql.ErrNoRows when no webhook matches both id and workspace.
func (s *Store) DeleteWebhookScoped(id, workspaceID string) error {
result, err := s.db.Exec(s.q("DELETE FROM webhooks WHERE id = ? AND workspace_id = ?"), id, workspaceID)
if err != nil {
return fmt.Errorf("delete webhook: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
// UpdateWebhookFailure increments or resets the failure count for a webhook.
// If failed is true, the failure_count is incremented. If it reaches the
// threshold of 10, the webhook is auto-deactivated.
// If failed is false, the failure_count is reset to 0 and last_triggered_at is updated.
func (s *Store) UpdateWebhookFailure(id string, failed bool) error {
ts := now()
if failed {
_, err := s.db.Exec(s.q(`
UPDATE webhooks
SET failure_count = failure_count + 1,
updated_at = ?,
active = CASE WHEN failure_count + 1 >= 10 THEN FALSE ELSE active END
WHERE id = ?
`), ts, id)
if err != nil {
return fmt.Errorf("update webhook failure: %w", err)
}
} else {
_, err := s.db.Exec(s.q(`
UPDATE webhooks
SET failure_count = 0,
last_triggered_at = ?,
updated_at = ?
WHERE id = ?
`), ts, ts, id)
if err != nil {
return fmt.Errorf("update webhook success: %w", err)
}
}
return nil
}