Commit Graph

4 Commits

Author SHA1 Message Date
xarmian fa66181b88 fix(webhooks): a DNS failure is retried, and a redirect may not turn the POST into a bodyless GET (BUG-3661, BUG-3662) (#2163)
* fix(webhooks): a DNS failure is retried, and a redirect may not turn the POST into a bodyless GET (BUG-3661, BUG-3662)

BUG-3661: the owner path's pre-delivery URL check returned the resolver's
error like a policy refusal, and attemptDeliver and checkRedirect made every
validation error permanent, so a DNS timeout or SERVFAIL ended the endpoint's
part in the event for good. The app path's dial turned resolver errors into
ErrRefused, which attemptApp maps to permanent. Now ValidateWebhookURLContext
returns a typed ResolveError from a lookup bound to the attempt's timeout (a
seam, lookupIP), attemptDeliver and checkRedirect treat it as transient, and
appfetch's dial returns resolver errors as a network error (ErrNotSent from
Poster.Post: transient, unsent). Policy refusals stay permanent.

BUG-3662: net/http follows a 301, 302 or 303 after a POST as a bodyless GET
and keeps the headers, so the receiver got a GET with a signature for a body
it never saw, and a 2xx was recorded as delivered. checkRedirect now refuses a
hop that changes the method or drops the body (permanent: the receiver's
configuration). 307 and 308 keep both and are followed.

Tests: resolver failure transient at delivery, at a redirect and on the app
poster, with policy controls staying permanent / refused; 301/302/303 refused
without sending the GET; 307/308 deliver the POST with its body and signature.
Each fix's mutant turns its test red.

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

* fix(webhooks): a dial the address screen refuses is permanent, and one deadline covers the whole attempt (BUG-3661, codex r1)

Medium: screenDialAddr refusals (a DNS answer that changed between the
URL check and the dial) were plain errors, so the Do error counted as
transient and a policy refusal was retried. They now wrap errDialBlocked,
which attemptDeliver treats as permanent. Low: the pre-delivery lookup had
its own deliveryTimeout before the request got another; both now run under
one attempt context. TestBUG3661_DialTimeBlockIsPermanent (validation sees a
public answer, the dialer resolves localhost) is red with the classification
removed.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 10:05:08 -04:00
xarmian c52f64e263 fix(netpolicy): one outbound address policy for URL import and webhooks (#1738)
* fix(urlimport): one address policy for URL import and webhooks (BUG-3358)

URL import refused only loopback, RFC 1918, link-local and CGNAT, so it
reached 198.18.0.0/15, 0.0.0.0/8, 240.0.0.0/4 and the other reserved ranges
webhooks already refused, and the import door returns the fetched body. Both
now call internal/netpolicy.Blocked, checked at dial time against the
address being connected to, which also adds IPv6 forms that embed IPv4
(NAT64, 6to4, IPv4-compatible) and the remaining special-purpose IPv6
blocks. Regression legs cover a literal address, a DNS answer and a
redirect.

* fix(netpolicy): registry gaps, public exceptions, NAT64 by embedded address, malformed input (BUG-3358)

Codex r1: add 192.88.99.0/24, 100:0:0:1::/64, 3fff::/20 and 5f00::/16;
allow the globally reachable exceptions inside 192.0.0.0/24 and 2001::/23;
judge the NAT64 well-known prefix by the IPv4 address it embeds instead of
refusing the prefix; refuse any slice that is not 4 or 16 bytes. The
redirect test now requires the policy refusal, and the dialer's literal
screen has its own leg.
2026-10-02 17:09:12 -04:00
xarmian ce46b6f190 fix(webhooks): enforce SSRF guard at dial time + widen deny-list (#836)
The webhook SSRF guard only validated the literal URL string at parse
time, so HTTP 302 redirects and DNS rebinding both reached internal IPs
(cloud metadata, RFC1918, docker services).

Enforce the guard at connect time: the delivery client's dialer Control
callback re-checks the actual resolved IP before the socket connects
(closing the DNS-rebind TOCTOU), and CheckRedirect re-runs
ValidateWebhookURL on every hop with a redirect cap. Proxy is pinned nil
so HTTP(S)_PROXY can't bypass the dialer's check.

Widen isPrivateIP to also deny CGNAT (100.64/10), IETF protocol
assignments (192.0.0/24), benchmarking (198.18/15), Class E (240/4),
broadcast, all multicast (224/4, ff00::/8), and the TEST-NET / IPv6
documentation ranges.

Fixes BUG-1993.

Claude-Session: https://claude.ai/code/session_01BoPkYhKqMiWPYmxQigeWsA
2026-07-07 15:23:25 -04:00
xarmian 8aa6481421 PHASE-12: Security Hardening for Pad Cloud (#67)
* feat: enforce RBAC role checks on all mutation endpoints (TASK-150)

Add requireMinRole helper and role enforcement to 30+ mutation handlers.
Viewers are now blocked from all state-changing operations, editors can
mutate items/docs/comments/views but not collections/webhooks/workspace
settings, and only owners can perform administrative operations.

Includes 11 integration tests with real auth covering viewer/editor/owner
access across items, collections, documents, comments, agent roles,
item links, and workspace operations.

* fix: scope search results to user's workspaces (TASK-151)

Search without a ?workspace= param previously returned results from all
workspaces in the database. Now the handler resolves the authenticated
user's workspace memberships and passes their IDs to the store query,
ensuring results only include items from workspaces the user belongs to.

Fresh installs (no users) retain unscoped search for backward compat.
Includes integration test proving cross-workspace isolation.

* fix: add webhook URL validation and SSRF protection (TASK-152)

Webhook creation now validates URLs before accepting them: only HTTP(S)
schemes allowed, embedded credentials rejected, private/reserved IPs
blocked (loopback, RFC1918, link-local, cloud metadata 169.254.169.254),
and hostnames are DNS-resolved to verify they don't point to private IPs.

Defense-in-depth check also added to the dispatcher's deliver function
so existing webhooks with unsafe URLs are blocked at delivery time.

* feat: add CSRF protection with double-submit cookie pattern (TASK-153)

Implements CSRF middleware that validates X-CSRF-Token header matches
the pad_csrf cookie on all state-changing API requests. Bearer token
auth, auth endpoints, and fresh installs are exempt. The frontend
client reads the CSRF cookie and attaches the header on mutations.

* feat: add per-endpoint rate limiting middleware (TASK-154)

Adds IP-based rate limiting for auth endpoints (5/min login, 3/hr
password reset, 5/hr registration) and user-based limits for API
(100/min) and search (30/min). Uses golang.org/x/time/rate with
automatic stale-entry cleanup. Adds chi RealIP middleware for
correct client IP behind proxies. Returns 429 with Retry-After.

* fix: sanitize error responses and remove PII from logs (TASK-155)

Replace all writeError(500, err.Error()) calls with writeInternalError
that logs the real error server-side and returns a generic message to
clients. Remove email addresses, user IDs, and password reset tokens
from log output to prevent PII leakage.

* feat: add security headers, configurable CORS, and secure cookies (TASK-160)

Add SecurityHeaders middleware (CSP, X-Frame-Options, nosniff,
Referrer-Policy, Permissions-Policy). Make CORS origins configurable
via PAD_CORS_ORIGINS env var. Add PAD_SECURE_COOKIES for TLS
deployments (sets Secure flag on session/CSRF cookies and enables
HSTS). Also adds X-CSRF-Token to CORS allowed headers.

* fix: address PR review — lazy router init and trusted IP for rate limits

Fix two issues flagged by Codex:

1. CORS/HSTS config was ignored because setupRouter() ran in New()
   before SetCORSOrigins/SetSecureCookies were called. Now uses
   sync.Once to lazily build the router on first ServeHTTP/Listen.

2. Rate limiter read X-Real-IP directly from untrusted headers,
   allowing clients to spoof IPs. Now uses RemoteAddr only (which
   chimiddleware.RealIP already sanitizes from trusted proxy headers).
2026-04-05 10:26:00 -04:00