mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-04 12:31:45 +00:00
46fa72ca0fa18ecd592477a77f650122bf5aa3fa
124 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
46fa72ca0f |
feat(server): log session IP changes, add optional PAD_IP_CHANGE_ENFORCE=strict (TASK-666) (#191)
* feat(server): log session IP changes, add optional PAD_IP_CHANGE_ENFORCE=strict (TASK-666)
Sessions stored a client IP at creation but never rechecked it. A stolen
cookie could be used from anywhere with no signal to the owner. This
change adds mid-lifetime IP-change detection without breaking legitimate
mobility (mobile roaming, VPN toggles, carrier NAT) by default.
- New audit action ActionSessionIPChanged captures {old_ip, new_ip} in
the audit metadata. Visible via the existing /api/v1/admin/audit-log.
- handleSessionIPChange wired into both SessionAuth (cookies) and
TokenAuth (padsess_ bearer). After UA check passes, compares stored
session IP to clientIP(r). On mismatch:
- log one audit row
- update the stored session IP so we don't spam the log
- strict mode: DeleteSession + 401 "session_ip_changed"
- default mode: let the request through
- Store.UpdateSessionIP lets middleware refresh the recorded IP without
tearing down the session.
- PAD_IP_CHANGE_ENFORCE=strict env var + ip_change_enforce TOML key +
Server.SetIPChangeEnforce setter (case-insensitive, trims whitespace).
- Table-driven tests cover log-only, strict rejection with session
destruction, and setter parsing edge cases.
Parent: PLAN-643 (OSS Security Hardening).
* fix(server): dedupe session-IP-change audit via CAS, handle browser vs API paths per Codex review
Addresses two P2 comments on PR #191:
1. Race: parallel requests after an IP change could each emit
ActionSessionIPChanged before any of them updated the stored IP,
producing duplicate audit rows for a single transition.
- Replace UpdateSessionIP with UpdateSessionIPIfEquals (compare-and-set
on ip_address). Only the request that actually rotates the stored
value logs; concurrent siblings lose the CAS and skip logging.
- New test TestSessionIPChange_CASDedupesRace fires 20 concurrent
requests from the new IP and asserts exactly 1 audit row.
2. Strict-mode 401 on non-API paths:
- In current routing the SPA is mounted on the root router outside
the auth Group, so SessionAuth only fires for /api/* in practice.
The original concern about JSON 401s on browser navigation doesn't
surface today, but defense-in-depth keeps the code forward-safe:
restructure handleSessionIPChange to return a four-state outcome
(Continue / AllowedLogged / Revoked / Terminated) and only write
the JSON 401 on /api/* paths. Revoked + non-API falls through
unauthenticated so a future SPA-in-group configuration would still
render a login screen instead of raw JSON.
- Clear the session cookie (MaxAge=-1) in strict rejection so the
browser stops sending the now-revoked token on the next request.
TestSessionIPChange_StrictClearsCookies verifies the Set-Cookie.
Parent: PLAN-643 (OSS Security Hardening), TASK-666.
* fix(server): strict mode destroys session atomically, never rotate stored IP when destroying (TASK-666)
Addresses Codex P1 on PR #191: previously we rotated the session's stored
ip_address via UpdateSessionIPIfEquals BEFORE attempting DeleteSession.
If the DELETE failed (transient DB error) the row remained alive —
rebound to the attacker's new IP — so follow-up requests saw stored IP
== client IP and passed handleSessionIPChange's "match, no-op" branch.
That silently defeated strict enforcement.
- New Store.DeleteSessionIfExists returns (bool, error) to serve as the
CAS primitive for strict mode: only the caller whose DELETE affected a
row emits the audit entry, and a DB error fails closed (500 — "Unable
to validate session") rather than letting the request through.
- handleSessionIPChange splits into two paths:
* log-only mode: UpdateSessionIPIfEquals for CAS dedup (unchanged)
* strict mode: DeleteSessionIfExists is the CAS; stored IP is NEVER
rotated so any failure leaves the session bound to the OLD IP and
subsequent requests from the new IP still mismatch + still reject.
- TestSessionIPChange_StrictDestroysSessionAtomically regression test
verifies a second request from the new IP with the same token still
fails after the first strict-mode rejection.
Parent: PLAN-643 (OSS Security Hardening).
* fix(server): exempt public API paths from strict IP-change termination (TASK-666)
Addresses Codex P2 on PR #191: SessionAuth runs for every /api/* path,
including public endpoints like /api/v1/auth/login, /api/v1/auth/register,
/api/v1/health, /api/v1/s/* (share links), and /api/v1/plan-limits. In
strict mode, a stale session cookie on those requests was rejected with
a 401 session_ip_changed BEFORE the public handler could run — the user
literally couldn't log back in because their own stale cookie blocked
the login call.
- Extract isPublicAPIPath as a shared helper between RequireAuth and
handleSessionIPChange so they can't drift out of sync.
- handleSessionIPChange strict-mode flow now: destroy session + clear
cookies + audit log (unchanged), then for public API paths return
Revoked so the handler still runs. For authenticated-only API paths
still return Terminated (401). For non-API paths return Revoked for
the SPA fallback.
- Updated TokenAuth Revoked handler to match: pass through unauth on
public paths, 401 on authenticated-only.
- TestSessionIPChange_StrictAllowsPublicAPIPaths regression test:
a stale session cookie on /api/v1/auth/login must NOT produce
session_ip_changed; /api/v1/plan-limits must still return 200.
Parent: PLAN-643 (OSS Security Hardening).
* fix(server): short-circuit SessionAuth on token auth + fix IPv6 clientIP parsing (TASK-666)
Addresses two more Codex comments on PR #191:
P1 — SessionAuth 401'd API-token-authenticated requests:
TokenAuth sets currentUser for user-owned tokens AND tokenWorkspaceID
for legacy workspace-scoped tokens. SessionAuth short-circuited only on
currentUser, so a workspace-scoped-token request that happened to carry
a stale session cookie with a mismatched IP would be rejected by the
IP-change strict path before RequireAuth could honor the token. Extend
the short-circuit to also check tokenWorkspaceID; either signal is
enough to say "token auth already succeeded, skip cookie validation".
P2 — clientIP mangled IPv6 addresses:
clientIP used strings.LastIndex(":") on RemoteAddr. For bare IPv6
addresses like "2001:db8::1" (which TrustedProxyRealIP writes verbatim
from X-Forwarded-For, no brackets/port), that strips the final hextet
to "2001:db8:" — unusable for comparison in the new IP-change audit
path and incorrect for rate-limit keys too. Switch to net.SplitHostPort
which handles both "host:port" and "[ipv6]:port", falling back to the
raw RemoteAddr when no port is present (the trusted-proxy rewrite
case).
Tests:
- TestClientIP_IPv6NotMangled covers IPv4 w/wo port, bracketed IPv6,
bare IPv6 (no port, no brackets), and loopback forms.
- TestSessionAuth_ShortCircuitsOnAPITokenAuth exercises the worst case:
strict mode + valid API token + stale session cookie + new client IP.
Request must succeed (token wins) and NO new session_ip_changed audit
row must appear.
Parent: PLAN-643 (OSS Security Hardening).
* fix(server): canonicalize IPs before session-IP-change comparison (TASK-666)
Addresses Codex P2 on PR #191: raw-string comparison of session.IPAddress
vs clientIP(r) would fire session_ip_changed spuriously when the same
IPv6 address arrived in different valid textual representations (the
trusted-proxy path writes X-Forwarded-For verbatim, and different hops
normalize differently — "2001:0db8::1" vs "2001:db8::1" etc.).
- canonicalIP helper: net.ParseIP + stringify to collapse equivalent
IPv6 forms (compressed vs expanded, case, leading zeros) and IPv4-in-
IPv6 into a single canonical string. Non-parseable inputs pass through
unchanged so debug/malformed values behave predictably.
- handleSessionIPChange compares and logs the canonical forms. The CAS
still passes session.IPAddress (the raw stored value) to the DB — the
compare-and-set is about row identity — but the new IP written in is
the canonical form so future comparisons are stable.
- TestCanonicalIP covers empty, IPv4, shorthand "::1", expanded 8-group
equivalent, mixed-case 2001:DB8::1, fully expanded 2001:0db8:…:0001,
and non-IP fallback.
Parent: PLAN-643 (OSS Security Hardening).
|
||
|
|
0a24078554 |
fix(server): derive CLI auth URL scheme from r.TLS, gate X-Forwarded-Proto on trusted proxies (TASK-665) (#190)
handleCreateCLIAuthSession previously accepted X-Forwarded-Proto from any
client to pick the URL scheme, letting an attacker forge https:// in the
terminal link printed by `pad auth login` on plain-HTTP self-host
deployments. Low-impact phishing (the user clicks in their own terminal),
but the safe default is to ignore unauthenticated proxy headers.
- Factor out cliAuthScheme(r, trustedCIDRs) with explicit precedence:
1. r.TLS != nil -> "https"
2. peer in PAD_TRUSTED_PROXIES -> use X-Forwarded-Proto (first value,
case-insensitive, must be "http" or "https")
3. otherwise -> "http"
- Use rawPeerAddr so the check works even after TrustedProxyRealIP has
rewritten r.RemoteAddr.
- Table-driven tests cover TLS, untrusted-peer spoofing, trusted-peer
forwarding, chained/case-insensitive/garbage X-Forwarded-Proto values.
Parent: PLAN-643 (OSS Security Hardening).
|
||
|
|
a2eaac4a37 |
fix(server): reject CORS wildcard when credentials are on (TASK-664) (#188)
PAD_CORS_ORIGINS accepted any string (including '*') while the CORS middleware ran with AllowCredentials=true unconditionally. Browsers refuse the combination per the Fetch spec, so a typo like PAD_CORS_ORIGINS=* "worked" in curl but failed silently from every real browser — and without an explicit carve-out, an anon cross-origin fetch still rode the victim's cookies when origins were empty. - parseCORSOrigins: explicitly drop '*' with a log warning. When '*' was the ONLY configured origin, fall back to localhost defaults rather than producing an empty allowlist. - corsAllowCredentials: new helper — AllowCredentials=true only when an operator has set PAD_CORS_ORIGINS. Default false keeps a browser on a different origin from piggy-backing cookies on the user's session when no remote origin was expected in the first place. - server.go: wire up corsAllowCredentials(s.corsOrigins) into the cors.Options. Tests: - TestParseCORSOrigins gains three '*'-handling cases (lone '*', mixed, trailing '*'). - TestCorsAllowCredentials covers empty/whitespace default, explicit origins, and tab-only input. Parent: PLAN-643 (OSS Security Hardening). |
||
|
|
e73196f590 |
fix(server): constant-time compare for CSRF token validation (TASK-659) (#187)
* fix(server): constant-time compare for CSRF token validation (TASK-659) The CSRF middleware compared the cookie and header tokens with Go's == operator, which short-circuits on the first byte mismatch. An attacker who can observe response timing can binary-search for the matching token prefix byte by byte — theoretically useful against a local attacker with precise timing, less so against remote attackers but still a hygiene fix. - middleware_csrf.go: switch to subtle.ConstantTimeCompare. Also explicitly check length equality first, because ConstantTimeCompare returns 0 for mismatched lengths and an earlier Go == check would leak a timing signal about "how many leading bytes matched before the length diverged." Existing CSRF tests (FreshInstallExempt, LoginSetsCSRFCookie, LogoutClearsCSRFCookie, AllMutationMethodsBlocked) continue to pass — the only semantic change is timing-safety on validation. Note on the task's HMAC binding suggestion: binding the CSRF token to the session via HMAC is tracked as a follow-up. It requires a stable server-side HMAC key (similar to the 2FA challenge secret), platform- settings persistence, and a session-cookie-dependent setCSRFCookie signature — larger change than this PR is scoped for. Parent: PLAN-643 (OSS Security Hardening). * fix(server): length-check CSRF as strings before allocating per Codex P2 Codex caught that converting both tokens to []byte up-front forces an allocation proportional to the attacker-controlled X-CSRF-Token header on every failing request — a mild DoS/GC-pressure vector. Compare string lengths first (no allocation), short-circuit on mismatch, and only convert to []byte when lengths match. The allocated path then runs subtle.ConstantTimeCompare for the timing-safe comparison. * fix(server): reject off-size CSRF tokens before allocating per Codex P2 Codex caught that the length-match check still allowed attacker- controlled equally-sized tokens of any size (up to MaxHeaderBytes) to trigger the []byte allocation pair. Since CSRF tokens are always csrfTokenLen*2 hex chars (64 bytes), we can safely reject any length that doesn't match the expected fixed size before allocating anything. - middleware_csrf.go: add expectedLen := csrfTokenLen * 2 (hex), reject any cookie/header whose length != expectedLen before converting to []byte. The subsequent subtle.ConstantTimeCompare then operates on fixed-size 64-byte copies. - middleware_csrf_test.go + handlers_auth_test.go: bump all test fixture tokens to 64 hex chars so the fixed-length validation accepts them. The test-only tokens are arbitrary hex (not generated by the real generator) — they just have to match the shape. |
||
|
|
169b79380d |
feat(auth): bigger recovery codes + per-challenge attempt limit (TASK-658) (#186)
* feat(auth): bigger recovery codes + per-challenge attempt limit (TASK-658) generateRecoveryCodes produced 4 bytes of randomness (32 bits) encoded as hex — below the NIST SP 800-63B floor for backup authenticators and grindable online at a few thousand attempts per second. The 2FA verify endpoint also had no per-challenge-token limit on recovery attempts, so a captured challenge could be used to fuzz the entire recovery-code space before the 5-minute expiry. Changes: - handlers_2fa.go: generateRecoveryCodes now emits 10 bytes (80 bits) of entropy encoded as unpadded base32 — 16 chars of [A-Z2-7]. Base32 avoids the 0/O, 1/I/l ambiguity that would bite users typing from a printed backup. 80 bits ≈ 2^80 ≈ 1.2 * 10^24, well above any online grinding budget. - handlers_2fa.go: handleTOTPLoginVerify now rate-limits recovery-code attempts per-challenge-token. Key = "rc:" + SHA-256 of the challenge (so the limiter map never stores the raw HMAC token). Burst of 6 — enough for a user who mistypes a dash or two, nothing more. - middleware_ratelimit.go: new RecoveryCode *ipRateLimiter in the RateLimiters struct, configured at 6/hour burst 6. Test: TestGenerateRecoveryCodes_EntropyShape asserts the 16-char base32 shape and that 8 codes generated in one batch are all distinct (a smoke check on the entropy source). Parent: PLAN-643 (OSS Security Hardening). * fix(auth): normalize recovery code input before hashing per Codex P1 Codex caught that base32 codes are uppercase but users entering them from a mobile keyboard or copy-pasting with dashes would fail the hash comparison, locking out legitimate users and burning per- challenge attempt slots for every typo. Add normalizeRecoveryCode(): strips whitespace and dashes, uppercases the result. handleTOTPLoginVerify runs user input through it before calling store.ConsumeRecoveryCode. Generated codes are already uppercase base32, so the normalization is a no-op for correctly typed codes but catches every common formatting mistake. Test: TestNormalizeRecoveryCode covers lowercase, dashes, whitespace, newlines, and empty input. * fix(auth): legacy lowercase-hex recovery code fallback per Codex P1 Codex caught a backward-compat break: pre-TASK-658 codes were generated via hex.EncodeToString (lowercase), but normalization now uppercases before hashing — so a user with the legacy stored hash typing their exact code is rejected and eventually locked out. After the normalized consume attempt fails, retry once with the raw trimmed input so the original lowercase hex form still validates. No extra rate-limit slot — the limiter.Allow() was already charged. New codes generated post-fix are uppercase base32, so the normalized attempt succeeds on the first try and the fallback is a no-op. |
||
|
|
2e21e8f534 |
fix(server): escape unsubscribe page via html/template + add strict CSP (TASK-657) (#185)
handleUnsubscribe piped email addresses through fmt.Sprintf straight
into an HTML string. If the Maileroo email validation ever regressed
to allow characters like '<', '>', or '"', the unsubscribe page would
reflect them into attribute context — a stored/reflected XSS surface
even on this single-purpose utility page.
- Switch to html/template which auto-escapes every {{.Field}} interpolation.
- Add a strict CSP (default-src 'none', script-src 'none', etc.),
Referrer-Policy: no-referrer, and X-Content-Type-Options: nosniff
to every response from this handler. The page needs none of those
sources anyway — only its own inline styles — so denying everything
else is defense in depth for any future regression.
Tests (handlers_unsubscribe_test.go):
- TestUnsubscribePage_EscapesUserInput feeds `"><script>alert('xss')</script>`
as an "email" and verifies the rendered body contains the escaped form
but not the raw tag.
- TestUnsubscribePage_SetsStrictCSP verifies the CSP directives and
nosniff header are present on every render path.
Parent: PLAN-643 (OSS Security Hardening).
|
||
|
|
baa1f75847 |
fix(server): cap JSON body + header size (TASK-663) (#184)
* fix(server): cap JSON body + header size (TASK-663) decodeJSON called json.NewDecoder(r.Body).Decode(v) with no size limit. Any client could POST a multi-GB JSON blob and watch Pad stream the whole thing into one allocation — a single request could OOM the process. - internal/server/server.go: wrap r.Body in http.MaxBytesReader(..., 2 MB) inside decodeJSON. Every legitimate payload (item, collection, auth, etc.) is well under 100 KB so 2 MB is several orders of magnitude above real traffic. Factor out decodeJSONWithLimit(maxBytes) so future bulk-import endpoints can opt in to a larger cap without removing the wrapper. - internal/server/server.go: set MaxHeaderBytes = 64 KiB on the http.Server (default is 1 MB). Plenty for cookies/auth/CORS while cheaply rejecting header-flood DoS. Test: decode_json_test.go covers the 3 MiB body rejection, a happy path, and a custom-limit override that rejects a 1 MiB body under a 256 KiB cap. Parent: PLAN-643 (OSS Security Hardening). * fix(server): bump workspace import JSON cap to 64 MiB per Codex P1 Codex flagged that handleImportWorkspace inherits the new 2 MiB default cap, but WorkspaceExport contains full collections, items, comments, and item_versions for the workspace — a realistic project backup routinely exceeds 2 MiB, so existing exports stop re-importing. Switch to decodeJSONWithLimit(64 << 20). 64 MiB is multiple orders of magnitude above any realistic single-workspace backup while still far from heap-exhaustion territory. |
||
|
|
f23113ab76 |
fix(server): drop cloud_secret query-param fallback (TASK-656) (#183)
handleGetUserByCustomerID accepted ?cloud_secret= for GET sidecar calls. Query values land in access logs — our StructuredLogger records path + raw query, and any fronting reverse proxy typically logs the same. A log file compromise therefore became a compromise of the cloud trust boundary. Remove the fallback in two places: 1. handleGetUserByCustomerID — only checks X-Cloud-Secret header now (or admin auth via cookie/token). Comment explains why the convenience fallback was removed. 2. hasCloudSecretMarker — no longer honors ?cloud_secret on the auth/CSRF bypass path. Header or body-only for POSTs. Sidecars must send Authorization via the X-Cloud-Secret header. Pad Cloud deployment needs to be updated in lockstep; release notes should call this out. Tests: - TestCloudAdminGate_QueryParamSecret_Rejected flips the prior backward-compat test: ?cloud_secret on /user-by-customer now returns 401 (was 404 pre-fix). - TestCloudAdminGate_HeaderSecret_StillAuthenticates confirms the header form still reaches the handler on the same endpoint. Parent: PLAN-643 (OSS Security Hardening). |
||
|
|
c2b67f5a9d |
fix(server): gate cloud admin endpoints via requireCloudMode (TASK-655) (#182)
* fix(server): gate cloud admin endpoints via requireCloudMode (TASK-655) middleware_auth.go:184-189 and middleware_csrf.go:44-48 permanently exempted /api/v1/admin/plan, /admin/stripe-customer-id, and /admin/user-by-customer from RequireAuth and CSRFProtect — by path, not by credential. In self-host mode these endpoints still responded to every anonymous network caller (with "Cloud mode not configured"), confirming their existence and telegraphing that the auth surface was non-standard. Three tightly-coupled changes: 1. Narrow both carve-outs from path-based to credential-based. The new isCloudSecretAuthAttempt(r) helper checks for X-Cloud-Secret header or legacy ?cloud_secret query-param; only requests that present one bypass auth/CSRF. Cookie-based admin callers continue through the normal session + CSRF gate. 2. Wrap the three endpoints in a dedicated requireCloudMode group. Self-host mode → 404, no endpoint-existence disclosure. 3. Admin callers via cookie now properly require CSRF for these endpoints (they previously bypassed), bringing them in line with every other /admin/* endpoint. Tests (cloud_admin_gate_test.go): - TestCloudAdminGate_SelfHost_Returns404 — anon + X-Cloud-Secret in self-host → 404 (requireCloudMode fires). - TestCloudAdminGate_NoCloudSecret_RequiresAuth — cloud mode + no secret → 401 from auth gate (not the old "Cloud mode not configured"). - TestCloudAdminGate_ValidCloudSecret_PassesAuthAndCSRF — sidecar with matching X-Cloud-Secret reaches the handler; neither 401 nor 403. - TestCloudAdminGate_QueryParamSecret_BackwardCompat — legacy ?cloud_secret= on GET still works (TASK-656 removes this next). Parent: PLAN-643 (OSS Security Hardening). * fix(server): scope cloud-secret auth bypass to cloud admin paths per Codex P0 Codex caught a regression in the first cut: isCloudSecretAuthAttempt(r) only checked for the presence of X-Cloud-Secret/?cloud_secret, so setting either header on ANY path (e.g. GET /api/v1/workspaces) would bypass RequireAuth globally. An anonymous attacker could list or create workspaces just by adding one of those markers. Add a cloudAdminPaths whitelist and require the request path to be one of the three cloud admin endpoints before honoring the bypass. Defined as a map so a future /api/v1/... route can't accidentally inherit it. Regression test TestCloudAdminGate_BypassScopedToCloudPaths: - GET /workspaces + X-Cloud-Secret → 401 (not bypass) - GET /workspaces?cloud_secret=x → 401 (not bypass) - POST /workspaces + X-Cloud-Secret → 4xx (CSRF 403 or auth 401) * fix(server): make cloud-secret path gate visible at call sites Codex re-flagged the path scoping on PR #182 — even after the fix, the helper name 'isCloudSecretAuthAttempt' made the path scoping invisible at the call site. Split into two primitives: - isCloudAdminPath(path) — path whitelist check - hasCloudSecretMarker(r) — header/query marker check Both middleware now combine them explicitly: if isCloudAdminPath(path) && hasCloudSecretMarker(r) { ... } Behaviorally identical to the previous fix — tests still show GET /workspaces with X-Cloud-Secret returning 401, POST /workspaces with X-Cloud-Secret returning 403. Just makes the invariant readable in RequireAuth and CSRFProtect without having to jump to the helper. * fix(server): preserve body-cloud_secret auth for sidecar POSTs per Codex P1 Codex caught that POST sidecar calls carrying cloud_secret only in the JSON body (the current pad-cloud sidecar behavior) would fail at RequireAuth/CSRFProtect after this PR — handler-level validation never runs. Breaking deployed sidecars isn't the intent of TASK-655; TASK-656 deprecates body+query cloud_secret in favor of X-Cloud-Secret header exclusively, but that's a separate migration. Add body peek to hasCloudSecretMarker for POST/PUT requests with application/json content-type: - Read up to 64 KB of r.Body into a buffer. - Replace r.Body with an io.NopCloser wrapping the buffer so downstream handlers can still decode the JSON. - Return true if the parsed body has a non-empty cloud_secret field. Parse errors and missing fields → false (request falls through to the normal auth rejection, no permissiveness). The peek only runs when the caller is already hitting a cloud admin path via the explicit isCloudAdminPath() gate at the call sites, so the body-read cost is bounded to three endpoints. Test: TestCloudAdminGate_BodySecret_BackwardCompat posts with cloud_secret in the JSON body and no X-Cloud-Secret header, asserts the request reaches the handler (404 from unknown user_id, not 401/403 from middleware). |
||
|
|
7d3b468fc8 |
feat(server): gate /metrics behind loopback + bearer token (TASK-653) (#180)
cmd/pad/main.go:277 unconditionally registered Prometheus metrics and internal/server/server.go:229 served /metrics with no auth/CSRF. Any caller on the network could read workspace counts, API usage patterns, and (via label enumeration) user/workspace IDs. Three-layer gate: 1. Loopback-only default. No PAD_METRICS_TOKEN configured → /metrics accepts loopback peers only (safe for self-hosters running Prometheus on the same host, which is the common case). Non-loopback peers get 403 with a clear message. 2. Bearer-token mode. PAD_METRICS_TOKEN set → every scrape must send "Authorization: Bearer <token>", compared in constant time. Missing or wrong header → 401 with WWW-Authenticate: Bearer realm="metrics". 3. Rate-limit/logging chain still wraps the endpoint from the outer router.Use calls. Wiring: - internal/config/config.go — MetricsToken field + PAD_METRICS_TOKEN env. - cmd/pad/main.go — plumb cfg.MetricsToken into SetMetricsToken. - .env.example — document PAD_METRICS_TOKEN with openssl-rand hint. - internal/server/server.go — metricsAuth middleware + subtle.ConstantTimeCompare. Tests: metrics_auth_test.go covers loopback allowed, LAN denied, missing/wrong/correct Bearer, non-Bearer scheme rejected, WWW-Authenticate header, and the SetMetrics-absent 404. Parent: PLAN-643 (OSS Security Hardening). |
||
|
|
ae1df43438 |
fix(auth): rotate sessions on password change, TOTP off, OAuth unlink (TASK-652) (#179)
* fix(auth): rotate sessions on password change, TOTP off, OAuth unlink (TASK-652)
handleUpdateCurrentUser previously only updated the password — an
attacker who already stole a session cookie could continue using it
forever even after the owner "rotated" their password. Same issue on
the two other credential-surface-mutating endpoints: disabling 2FA
(handleTOTPDisable) and unlinking an OAuth provider (handleOAuthUnlink).
Extract rotateSessionsAfterCredentialChange:
1. store.DeleteUserSessions(userID) — kills every existing session.
2. Mint a fresh session for the caller via store.CreateSession.
3. Set the new session cookie + CSRF cookie so the caller stays
logged in and doesn't have to re-auth on the current tab.
Call the helper from all three handlers. Best-effort on the delete
step — if it fails we log and still mint a new cookie so the caller
isn't stranded.
Test: TestPasswordChange_InvalidatesOtherSessions establishes two
sessions, changes the password from one, and asserts that (a) a new
session cookie is set, (b) the OTHER session token is 401, and (c)
the original caller token is also 401 (replaced by the fresh one).
Parent: PLAN-643 (OSS Security Hardening).
* fix(auth): return fresh token for Bearer callers after rotation per Codex P2
Codex caught that rotateSessionsAfterCredentialChange only reissued
the caller's session via Set-Cookie. CLI / API clients that authenticate
with 'Authorization: Bearer padsess_...' would be locked out on the
next request after any credential change.
Change the helper to return the new token string. Each handler
(handleUpdateCurrentUser, handleTOTPDisable, handleOAuthUnlink) now
includes the fresh token in its JSON response body so Bearer-only
clients can update their stored credential. Cookie-based clients
continue to pick up the new session transparently via Set-Cookie.
|
||
|
|
d86211fcdc |
feat(auth): per-email login rate limiter (TASK-651) (#178)
* feat(auth): per-email login rate limiter (TASK-651) handleLogin is rate-limited per-IP (5/min, in middleware_ratelimit.go), which is effective against a single attacker but useless against a botnet rotating source IPs to spray one victim's password reset email. Add a second limiter keyed on the lowercased email, 10 attempts/hour burst 10. Consumed inside handleLogin on every attempt (success or failure) — a legitimate user remembers their password within 1-2 tries and never hits the limit, but an attacker pounding one account from 50 IPs is locked out after 10 attempts regardless of where those attempts originate. The blocked attempt is logged to the audit log as ActionLoginFailed with reason=email_rate_limited so admins can see which accounts are being sprayed. Tests: - TestHandleLogin_PerEmailRateLimit exhausts the email limit from 10 distinct IPs, then verifies a fresh-IP attempt against the same email gets 429 while a different email from another fresh IP still gets the ordinary 401. - TestHandleLogin_EmailCaseInsensitive verifies the limiter key is normalized — alternating MIXED@/mixed@/Mixed@ all count against the same bucket. Parent: PLAN-643 (OSS Security Hardening). * fix(auth): retain AuthEmail buckets for 2h per Codex P1 Codex caught that ipRateLimiter's cleanup evicts inactive keys after 30 min, which defeats the 10/hour AuthEmail budget: an attacker bursts 10, waits ~30 min for eviction, bursts another 10 — 20 guesses/hour, not 10. Make retention per-config, and set AuthEmail's to 2 hours (≥ 2x the refill window) so the bucket survives the natural pause between spraying rounds. Per-IP limiters keep the 30-min default since their refill is sub-minute. * fix(auth): bound AuthEmail bucket keys by plausibility per Codex P1 Codex caught that the 2-hour retention window on AuthEmail creates a memory-DoS vector — a distributed attacker can POST many long garbage 'email' strings to /api/v1/auth/login and grow the limiter map without bound, since each call inserts a new bucket before any email validation. Add isPlausibleEmail() pre-filter: reject >254 chars (RFC 5321 cap) and strings without an '@' in the interior. Only plausible emails get a bucket; garbage still gets 401 from the password check below but never makes it into the map. Test: TestHandleLogin_ImplausibleEmail_NoBucketCreated hammers the endpoint with 500-char garbage from many IPs and verifies the AuthEmail map never holds a key starting with that garbage pattern. TestIsPlausibleEmail covers empty, missing @, leading/trailing @, over 254, unicode local part. |
||
|
|
0657880d14 |
fix(auth): bind invitation acceptance to invitee email (TASK-650) (#177)
handleRegister and handleAcceptInvitation previously accepted any authenticated/creatable account as the invitee. If an attacker learned the invitation URL (email forwarding, shared screenshot, guessed code) they could register a brand-new account at their own address and claim the workspace seat, or sign into an existing account and attach the invitation to it. Add a case-insensitive strings.EqualFold check between the invitee email (inv.Email) and: - the signup form's Email field in handleRegister, before creating the account; and - the authenticated user's Email in handleAcceptInvitation. Mismatch returns 403 invitation_email_mismatch with a clear message pointing the user at the intended address. EqualFold normalizes the casing mismatch against the store's own ToLower() at create time. Parent: PLAN-643 (OSS Security Hardening). |
||
|
|
33b3f21a2c |
feat(auth): expire workspace invitations after 14 days (TASK-649) (#176)
* feat(auth): expire workspace invitations after 14 days (TASK-649)
A workspace invite code lives forever until accepted. A leaked code —
email forwarding, stale screenshot, git history — lets any attacker who
registers the invitee's email claim the workspace seat months or years
later.
Introduce a 14-day default expiry:
- New migration (SQLite 044 + Postgres 024) adds expires_at TEXT to
workspace_invitations with an index, backfilling existing rows to
created_at + 14 days so old codes also age out.
- Store CreateInvitation sets expires_at = now + InvitationTTL;
GetInvitation/GetInvitationByCode/ListWorkspaceInvitations read and
populate ExpiresAt. Legacy rows with NULL expires_at are treated as
non-expiring (backward compat for codes created before the migration).
- Model gains ExpiresAt *time.Time and an IsExpired() helper, nil-safe.
- handleAcceptInvitation returns 410 Gone "expired" for expired codes.
- handleRegister (invitation path) returns 410 Gone with the same
message so the signup flow surfaces expiry distinctly from "invalid
code".
Tests: models.TestWorkspaceInvitation_IsExpired covers nil/past/future
plus a nil-receiver safety check.
Parent: PLAN-643 (OSS Security Hardening).
* fix(store): backfill invitation expires_at in RFC3339 per Codex P1
Codex caught that the first cut of migration 044 (SQLite) and 024 (Postgres)
emitted space-separated timestamp strings, which parseTime silently rejects —
legacy invitations would all show up as zero-time ExpiresAt and be treated
as already-expired right after upgrade.
- SQLite: switch to strftime('%Y-%m-%dT%H:%M:%SZ', created_at, '+14 days').
- Postgres: use to_char(..., 'YYYY-MM-DD"T"HH24:MI:SS"Z"').
Add regression tests:
- TestCreateInvitation_SetsExpiresAt — fresh invitations get expiry ~14d out.
- TestMigration044_BackfillProducesRFC3339 — inserts a legacy row with NULL
expires_at, applies the same backfill expression as the migration, and
verifies the round-tripped ExpiresAt is non-zero, parses correctly, and
is ~InvitationTTL after created_at.
* fix(store): drop AT TIME ZONE cast in PG backfill per Codex P2
Codex flagged that '(timestamp + INTERVAL) AT TIME ZONE UTC' yields a
timestamptz, and to_char(timestamptz, ...) renders using the session's
TimeZone — on a non-UTC Postgres instance, legacy invitations get
offset-shifted values mislabeled with a 'Z' suffix.
created_at is already stored as UTC text, so casting it to a naive
timestamp and doing the interval math without further conversion is
both correct and tz-independent. to_char on a plain timestamp uses the
stored value as-is and the hardcoded 'Z' suffix labels it accurately.
|
||
|
|
fc5a54dff7 |
fix(server): read raw TCP peer for loopback check (TASK-662) (#175)
* fix(server): read raw TCP peer for loopback check (TASK-662) TrustedProxyRealIP rewrites r.RemoteAddr when the peer is a trusted proxy. Without additional defense, an attacker reaching a trusted reverse proxy could set X-Forwarded-For: 127.0.0.1 and trick the bootstrap loopback check into accepting them as a local caller — reopening the full-instance-takeover path that TASK-660 closed at the spoof layer. Add CapturePeerAddr middleware that runs BEFORE TrustedProxyRealIP and stashes the untampered r.RemoteAddr in request context. Change requestIsLoopback to read via rawPeerAddr(r) (context-first, with a safe fallback for test paths that skip the middleware). r.RemoteAddr stays the rewritten value for the rate-limiter / audit-log paths that actually want the client's IP. Tests cover: direct loopback → true; direct LAN → false; trusted proxy forwarding spoofed 127.0.0.1 → false; untrusted peer with spoofed XFF=127.0.0.1 → false; and that rawPeerAddr falls back to r.RemoteAddr when CapturePeerAddr is absent. Parent: PLAN-643 (OSS Security Hardening). * fix(server): require loopback peer AND no proxy headers for bootstrap (Codex P1) Codex caught a regression in the initial PR: reading rawPeerAddr(r) made every request through a same-host reverse proxy look loopback, so a Caddy or nginx on 127.0.0.1 forwarding public traffic would let attackers reach the bootstrap endpoint from the internet. Tighten the rule to two independent conditions: 1. The untampered TCP peer is a loopback address. 2. Neither X-Forwarded-For nor X-Real-IP is set. A legitimate local CLI calling Pad directly satisfies both. A reverse proxy forwarding public traffic always sets the forwarding headers, so the presence of either disqualifies the request. The raw-peer check still defeats X-Forwarded-For spoofing from non-loopback attackers, and now also handles the Codex-flagged scenario where a local proxy is trusted or left misconfigured. Tests updated to cover: direct loopback no-headers allowed; loopback peer + XFF rejected; loopback peer + X-Real-IP rejected; IPv6 loopback allowed. |
||
|
|
ec9edef68c |
fix(server): gate RealIP on PAD_TRUSTED_PROXIES (TASK-660) (#173)
Replace the unconditional chimiddleware.RealIP with a middleware that only trusts X-Real-IP / X-Forwarded-For when the direct TCP peer is within a configured CIDR. With the safe default (PAD_TRUSTED_PROXIES unset) proxy headers are ignored entirely — the real TCP peer address is used for rate limiting, the bootstrap loopback check, and audit logs. Why: previously any client could set X-Forwarded-For to bypass per-IP rate limits AND the bootstrap loopback check (handlers_auth.go). On a direct-exposed Docker deploy (see M6, TASK-661) this compounded into a full-takeover chain. Gating RealIP breaks that chain even when the operator forgets to firewall the port. - internal/server/middleware_realip.go — new TrustedProxyRealIP middleware + ParseTrustedProxyCIDRs helper (accepts CIDRs or bare IPs, invalid entries logged+skipped, empty = nil result = no-op middleware). - internal/server/server.go — swap chimiddleware.RealIP for the gated version; add trustedProxyCIDRs field and SetTrustedProxies wiring. - internal/config/config.go — TrustedProxies field + PAD_TRUSTED_PROXIES env var. - cmd/pad/main.go — plumb config to the server. - internal/server/middleware_realip_test.go — covers no-trust default, untrusted peer, trusted peer with X-Real-IP, X-Forwarded-For first entry, and invalid header. Parent: PLAN-643 (OSS Security Hardening). |
||
|
|
204d63151f |
feat(server): strict-dynamic CSP + fail-fast missing index.html (TASK-375) (#172)
Completes the remaining items on the nonce-based CSP work: 1. Add 'strict-dynamic' to script-src. In CSP-L3 browsers this supersedes the 'self' host-list, so a future XSS that injects <script src="//evil"> is blocked even though 'self' is still listed (kept as fallback for older browsers). The SvelteKit bootstrap script already dynamically imports the runtime chunks, which is exactly the pattern strict-dynamic is designed to permit. 2. Fail fast when the embedded index.html can't be read. The previous silent-swallow returned blank HTML to every SPA request, which is a broken build that the operator should notice immediately. Panic at startup so the server refuses to come up with a broken UI. Parent: PLAN-643 (OSS Security Hardening). |
||
|
|
4297689e23 |
fix(server): add script-src-attr 'none' to CSP (TASK-648) (#171)
Inline event handlers (onerror, onload, onclick, …) bypass the script-src directive per CSP spec. Without script-src-attr 'none' an attacker who slips markup past the DOMPurify sanitizer can still execute JavaScript via event attributes — defeating the whole point of the nonce-based script-src. Add 'script-src-attr 'none'' to both CSP headers: - internal/server/middleware_security.go — strict policy for API responses - internal/server/server.go — nonce-based policy for HTML pages Defense-in-depth for TASK-647 (comment markdown sanitizer) and for any future regression in HTML-emitting paths. Parent: PLAN-643 (OSS Security Hardening). |
||
|
|
115b33849e |
feat(templates): software starter pack + idempotent seeding (TASK-612) (#144)
* feat(templates): software starter pack + idempotent seeding (TASK-612) Ship the software templates (startup, scrum, product) with a curated starter pack of conventions + playbooks so new workspaces feel "batteries included" rather than empty shells. The pack is a safe, small subset drawn from the existing convention/playbook library — the library itself remains the full catalog for interactive onboarding. Starter pack contents --------------------- Conventions (4): - Conventional commit format (on-commit, should) - Never push directly to main (on-commit, must) - Run tests before completing tasks (on-task-complete, must) - Review your own changes before PR (on-pr-create, should) Playbooks (2): - Implementation Workflow (on-implement) - Code Review Process (on-review) The pack is materialized by looking up library items by title and converting them to SeedConvention / SeedPlaybook via json.Marshal of the expected field shape. When the library's wording changes, the template's seed content changes automatically. Store-side changes ------------------ SeedCollectionsFromTemplate is now idempotent with respect to seed items: items are only created in collections that were freshly created during the current call (tracked via a freshlyCreated set). That's the invariant that lets the server's startup auto-upgrade safely re-run on every boot without duplicating items across every workspace in the DB. Empty template name preserves the old behavior (default collections, no starter pack) — this keeps backward compatibility for callers that don't pass a template, including the server-startup auto-upgrade path and all existing server tests. Explicit "startup" / "scrum" / "product" now gets the starter pack. Tests ----- - TestSoftwareStarterPacksPopulated — guards against library-title drift - TestSoftwareTemplatesShipStarterPacks — each software template ships a pack - TestSeedCollectionsFromTemplateSeedsStarterPack — end-to-end seeding works - TestSeedCollectionsFromTemplateIdempotentWithSeedItems — re-seed doesn't duplicate Parent: PLAN-609. * fix(cli): default pad init to startup template when --template is omitted Per Codex review on PR #144. Without this, `pad workspace init` without `--template` no longer seeded the starter pack, even though startup is documented as the default. The fix lives in ensureWorkspace (shared by both init.go and the workspace creation command in main.go) — empty flag is rewritten to "startup" there. Tests and other direct API callers that want an empty workspace still pass Template="" through. * fix(cloud): auto-create workspace passes startup template for starter pack Per Codex review iteration 2 on PR #144. The auto-create cloud-signup flow calls SeedCollectionsFromTemplate with an empty template, which after this PR's semantics meant new cloud workspaces got no starter conventions/playbooks. Pass "startup" explicitly to match the CLI init behavior. * fix(store): propagate collection lookup errors during seeding Per Codex review iteration 3 on PR #144. seedItem previously treated any error from GetCollectionBySlug as a silent no-op, which hid real DB lookup failures — a transient error during workspace creation would make seeding appear successful while conventions/playbooks were in fact missing. Now we distinguish the two cases: - err != nil → propagate so callers can detect partial init - coll == nil → benign (template references a slug not in its collections list; template-author bug, no-op) * fix(store): idempotent seeding by item title (partial-init recovery) Per Codex review iteration 4 on PR #144. The previous design gated item seeding on collections being freshly-created-in-this-call, which trapped partially-initialized workspaces: if a DB error fired between collection creation and item seeding, a retry would see the collections already existed and skip every remaining seed item. Switch to title-based idempotency. Before inserting a seed item we list the target collection's existing items (once per collection, via a small cache) and skip any whose title already exists. That makes seeding: - Idempotent: re-running a template doesn't duplicate items - Recoverable: retrying fills in missing items after partial init - Retry-safe: the auto-upgrade path can re-run safely on every boot New test TestSeedCollectionsFromTemplateRecoversPartialInit exercises the recovery path explicitly. |
||
|
|
73a6e1f3a9 |
feat(templates): categorize WorkspaceTemplate + hide demo (TASK-610) (#142)
Refactor the WorkspaceTemplate struct to carry the metadata and domain- specific seed packs needed for the upcoming non-software templates. - Add Category, Icon, Hidden, Conventions, Playbooks fields to the WorkspaceTemplate struct. Existing fields (Name, Description, Collections, SeedItems) unchanged. - Define SeedConvention and SeedPlaybook types so templates can carry domain-specific rules and workflows (populated in a follow-up task). - Introduce category constants (software, people, research, content, operations, personal). - Assign Category=software and Icon to startup (🚀), scrum (🏃), product (📦). Mark demo (🎬) as Hidden so it no longer appears in the picker while remaining buildable by explicit --template demo. - Split ListTemplates() into a filtered picker view and a new ListAllTemplates() for internal tooling. - Expose category and icon on the /workspaces/templates API response so the web picker can group by category in a follow-up task. - Add package tests for hidden-filtering and picker metadata invariants (the package previously had no tests). Parent: PLAN-609. |
||
|
|
9e7daa779f |
feat: tie done-detection to the board group-by field (TASK-604) (#140)
* feat: tie done-detection to the board group-by field
Closes TASK-604. Make "is this item done?" follow the collection's
settings.board_group_by rather than the hardcoded `status` key. If a
collection's board is grouped by `resolution`, then resolution's
terminal options drive dashboard counts, progress bars, changelog,
and starred-items filtering. Collections without an explicit
board_group_by (every collection today) continue to behave exactly
as before because the fallback resolves to `"status"`.
Why this shape
- No ambiguity: one field per collection wins. No reconciling
"status says in-progress, resolution says fixed."
- One JSON path to swap: every $.status query becomes
$.<done_field>. No dynamic OR across schema-discovered fields.
- Matches the mental model: the field you organize the board by is
the field that represents the item's current state. The old
mismatch (board grouped by X, "done" count from status) is a
latent bug this resolves.
- Non-breaking: board_group_by defaults to nil → DoneFieldKey
returns "status" → behavior identical to pre-TASK-604.
Model layer (internal/models/terminal.go)
- DoneFieldKey(schema, settings) resolves the done-field key with a
fallback chain: valid select on schema → that field, else "status".
- TerminalValuesForDoneField(schema, settings) returns (fieldKey,
values) honoring the done field, falling back to
DefaultTerminalStatuses when the resolved field has no
terminal_options.
- TerminalPlaceholdersForDoneField(schema, settings) is the SQL
convenience returning (fieldKey, placeholders, args).
- IsTerminalItem(fields, schema, settings) is the canonical
Go-side membership check.
- Legacy API (TerminalStatusesFromSchema, IsTerminalStatus,
TerminalStatusPlaceholders) kept as back-compat wrappers that
delegate with empty settings — resolve to "status" for callers
that don't have settings in scope yet.
SQL callers migrated to the new helpers
- internal/store/collections.go ListCollections active-count query
- internal/store/items.go GetItemProgress + GetAllItemProgress:
- New collectionDoneFilter type + childrenDoneFiltersFor{Parent,
Collection} + doneFiltersForWorkspace helpers load each
candidate collection's (schema, settings) and resolve per-
collection done keys + terminals.
- buildChildrenDoneExpr(filters, alias) compiles filters into a
single SQL boolean expression using per-collection OR clauses:
((alias.collection_id=? AND LOWER(...)
IN (?,?)) OR (alias.collection_id=? AND LOWER(...)
IN (?,?)) ...)
- Each child item is evaluated against its own collection's
done rules, so mixed-collection child progress is correct
without a global union hack.
- internal/store/agent_roles.go GetRoleBreakdown + Go-side filter
- internal/store/item_stars.go starred-items filtering now uses a
collectionDoneContext map (schema + settings) and IsTerminalItem.
Go-side callers migrated
- internal/server/handlers_dashboard.go: buildSchemaMap →
buildDoneContextMap (carries settings), isItemTerminal →
isItemDone (evaluates against the done field). 7 call sites
updated.
- internal/server/handlers_items.go: plan-progress recompute and
per-item /progress endpoint now use the done-context approach.
Left status-specific (per task scope)
- Link-payload $.status extracts in items.go getItemLink /
GetItemLinks / GetParentForItem — these populate
link.SourceStatus / link.TargetStatus, which are status-specific
by design.
- cmd/pad reconcile paths — no schema in scope, default-list
fallback is the right call.
- search.go facet "status breakdown" — a different UX concept
(bucket search results by status values) than done-detection.
Web UI reactivity
- FieldEditor: new activeDoneField prop. Each modal derives it from
boardGroupBy with the same fallback rule as the Go DoneFieldKey.
- Fields tab: the "Done?" column header on each select field renders
an "Active" green pill when that field is the board group-by, or a
muted "Saved" pill + inline hint otherwise ("Switch the board
group-by to <key> to make them drive done-detection"). Reactive to
boardGroupBy changes in the Display tab.
- DisplaySettingsEditor: "Board group by" label gets a helper line
explaining the new responsibility.
Tests
- internal/models/terminal_test.go: 13 unit tests covering fallback
resolution, placeholder args, membership (case-insensitive), and
back-compat shim semantics.
- internal/store/done_field_test.go: 3 integration tests:
1. Bugs collection grouped by resolution → items with terminal
resolution values count as done; items with status=fixed but
resolution=open do NOT count as done (proves status is no
longer consulted when it isn't the done field).
2. Collection without board_group_by still uses status terminals.
3. Mixed-collection children: each child evaluated against its
own done rules.
All pass alongside the full existing suite.
* fix: restrict done field to select (reject multi_select)
Two linked Codex P1 findings on PR #140, both rooted in the same
gap: multi_select fields store their values as JSON arrays, but both
the Go-side membership check (IsTerminalItem) and the SQL done
expression (buildChildrenDoneExpr) assume a scalar string. Naively
accepting multi_select as a done field would silently miss items
whose terminal value is one of several in the array — dashboards
and progress would report wrong counts.
Rather than implement array-containment semantics across both
paths (which would require deciding "any terminal value → done" vs
"all terminal values → done", SQL-dialect-aware JSON-contains, and
new tests for both shapes), close the gap with a constraint: only
select fields qualify as a done field. If array semantics become
a requirement later, that's a focused follow-up that can update
both paths together with a clear definition.
Changes
- DoneFieldKey and TerminalValuesForDoneField: loop bodies now
match only `select`, not `select || multi_select`. A
board_group_by pointing at a multi_select field falls back to
'status' — matching the rule for non-existent or non-select
fields.
- IsTerminalItem: docstring made the scalar contract explicit;
non-string values (which would be the multi_select array shape)
already returned false, which is now the deliberate behavior.
- buildChildrenDoneExpr: added a doc note that the scalar
JSON_EXTRACT path is correct because the upstream resolution
only hands us select fields.
- Web UI: EditCollectionModal + CreateCollectionModal derive
activeDoneField matching the backend rule (select only), and
FieldEditor.isActiveDoneField gates on field.type === 'select'.
A multi_select field never lights up the green "Active" pill now,
even if a user somehow pointed board_group_by at one.
Tests
- Replaced TestDoneFieldKey_AcceptsMultiSelect with
TestDoneFieldKey_RejectsMultiSelect. Asserts that a multi_select
board_group_by falls back to 'status' instead of being honored.
- Existing 12 unit tests + 3 integration tests all still pass.
* fix: include soft-deleted collections in done-filter loaders
Two related Codex P2s on PR #140. The done-filter loaders were
limiting their SELECT to collections with deleted_at IS NULL, but
the outer callers (GetItemProgress, GetAllItemProgress,
GetRoleBreakdown) count items regardless of their collection's
deleted_at. Net effect: after a collection was soft-deleted, its
items lost their per-collection clause in buildChildrenDoneExpr and
were always evaluated as non-terminal — undercounting done in plan
progress and inflating active counts in the role breakdown.
Fix
Drop the `c.deleted_at IS NULL` guard from all three filter
loaders:
- childrenDoneFiltersForParent
- childrenDoneFiltersForCollection
- doneFiltersForWorkspace
Soft-deleted collections still have valid schema + settings rows in
the DB, so the done rules remain applicable until a hard delete
cascades. This also matches what the outer queries count: if they
include items from a soft-deleted collection, the filter loaders
must too.
Regression test
TestGetItemProgress_HonorsSoftDeletedChildCollections:
1. Create a parent + two children in a child collection where one
child is done and one is open — assert done=1.
2. DeleteCollection on the child collection (soft-delete).
3. Re-run GetItemProgress — assert done is still 1, not 0.
Fails before the filter-loader fix, passes after.
* fix: avoid N+1 in plans progress + preserve done fallback on bad schemas
Two Codex P2s on PR #140.
P2: Avoid N+1 list-collection queries in plans progress
handlePlansProgress's restricted path was calling s.store.
ListCollections solely to build a ctxMap, but ListCollections runs a
separate active-item COUNT query per collection (collections.go),
burning O(number of collections) round-trips on every call. In
larger workspaces this materially inflates latency and can cause
timeouts. Add a lightweight Store.ListCollectionsMinimal that
returns only the ID / Schema / Settings needed for done-context
construction and skips the count queries entirely. Handler switches
to it.
P2: Preserve done fallback for unparseable collection schemas
scanCollectionDoneFilters was `continue`-ing past collections whose
schema failed to parse. Because buildChildrenDoneExpr composes a
per-collection OR clause and only applies the default-list fallback
when NO filters are constructed overall, a single malformed
collection could leave its items without a matching clause —
silently marking them as perpetually active in progress / role /
starred queries. Emit a fallback filter (status + DefaultTerminal-
Statuses) for that collection instead of skipping it, matching
pre-TASK-604 behavior for its items while still honoring the
configured rules for every other collection.
* fix: sanitize done-field keys + cover granted-item collections
Two more Codex findings on PR #140.
P1: Sanitize done-field keys before embedding SQL JSON paths
buildChildrenDoneExpr passes the resolved done-field key straight
into JSONExtractText, whose dialect implementations interpolate it
as a string literal inside `json_extract(..., '$.<key>')` /
`-->>'<key>'`. Schema / settings rows are persisted without backend-
side key validation, so a crafted board_group_by (e.g. a key with
quotes, semicolons, or SQL metacharacters) could break the
resulting query or inject. Since TASK-604 made done-field
resolution dynamic, this needs a chokepoint.
Fix: DoneFieldKey now refuses to resolve to any candidate that
doesn't match ^[a-zA-Z][a-zA-Z0-9_]*$ and falls back to the literal
"status" (which is always safe). The pattern matches the convention
already in use for search-field filtering in internal/server/
handlers_search.go.
Added TestDoneFieldKey_RejectsUnsafeKeys covering injection-shaped
strings, dots, dashes, leading digits, empty strings, and spaces.
P2: Include granted-item collections in dashboard done context
The dashboard was filtering `collections` by visibility BEFORE
building ctxMap, but allItems can still include items from
collections outside the visibility set via item-level grants
(dashItemIDs). Those items missed their own done-rules and
fell back to the status-default, misclassifying them for guests
with item-level grants in collections that use a non-status done
field.
Fix: build ctxMap from ListCollectionsMinimal(workspaceID) first —
always covering every collection in the workspace — then apply
visibility filtering to `collections` for the summary section only.
isItemDone now sees the real done rules for every item the
dashboard iterates, regardless of how visibility surfaced it.
* fix(web): mirror backend safe-key check in activeDoneField derivation
Codex P2 on PR #140. The previous commit added a safe-key regex on
the backend (DoneFieldKey rejects keys outside ^[a-zA-Z][a-zA-Z0-9_]*$
and falls back to "status"), but the Web activeDoneField derivation
in both modals only checked type === 'select'. For legacy / API-
created schemas carrying keys like `resolution-v2` or `foo.bar`, the
Fields tab would display an "Active" green pill on that field even
though the server silently ignores it and falls back to status. Users
could configure terminal options on the wrong field and never see
them take effect.
Fix: export isSafeDoneFieldKey from field-editor-types.ts (a tiny
helper wrapping the same regex the backend uses) and gate both
modals' activeDoneField derivations on it. Unsafe keys fall back to
'status' in the UI, matching the backend's behavior exactly —
Active/Saved pills are now truthful.
|
||
|
|
be0ae3d8f5 |
Revert "fix: accept password confirmation at unlink for unupgraded users"
This reverts commit
|
||
|
|
c94fc8dd6b |
fix: accept password confirmation at unlink for unupgraded users
BUG-588 follow-up: users who signed up with email/password and later linked a single OAuth provider are backfilled as password_set=false (because the backfill only flags users with no OAuth linked), and if they're currently logged in via OAuth they can't complete the ValidatePassword path that would flip the bit. They remain blocked from unlinking that only provider. Accept an optional password in the /auth/oauth-unlink request body. When the user has no other sign-in method and password_set is still false, the handler verifies the supplied password via ValidatePassword (which also upgrades password_set on success) and then allows the unlink. No password supplied → same "cannot unlink your only sign-in method" error as before, with a slightly more actionable message. |
||
|
|
e328844a1b |
fix: resolve five open bugs (BUG-585, BUG-586, BUG-588, BUG-589, BUG-590)
BUG-585 — Code-block copy no longer includes ``` fences
Editor.svelte: ProseMirror plugin overrides copy/cut when the selection
is inside a code_block node and writes raw textBetween to the clipboard.
NodeView for non-mermaid code blocks now shows a hover "Copy" button that
uses the existing copyToClipboard() util (with execCommand fallback).
BUG-586 — Wiki-link picker matches on item ref
Editor.svelte: getFilteredLinks() now also matches formatItemRef(item),
so typing [[DOC-535]] finds items by their issue ID. Picker dropdown
shows the ref as a badge; {#each} key switched to doc.id so duplicate
titles across collections don't collide.
BUG-588 — Can unlink OAuth provider when password is configured
Adds a password_set column to track whether a user has a usable
password vs. the random placeholder hash given to OAuth users.
CreateUser sets it true, UpdateUser sets it true when a password is
provided, and ValidatePassword auto-upgrades it on any successful
email/password login (which transparently upgrades pre-existing users
who linked OAuth after signing up with a real password — the OAuth
placeholder hash cannot match user-supplied plaintext, so this is safe).
handleOAuthUnlink now permits removing the last provider when
user.HasPassword() is true.
BUG-589 — Pre-auth pages render standalone
+layout.svelte: isAuthPage now also matches /forgot-password and
/reset-password/* so those pages don't inherit the authenticated
sidebar/topbar layout.
BUG-590 — Search no longer crashes with null results
store.Search() returned a nil Results slice on no-match queries, which
Go marshals as JSON null; CommandPalette then crashed on results.length.
Backend now normalizes nil to []SearchResult{} before returning.
CommandPalette also coalesces resp.results ?? [] on the initial search
and loadMore paths as belt-and-suspenders hardening.
|
||
|
|
999bd3cfca |
feat: add pagination and sorting to search API (#123)
* feat: add pagination and sorting to search API Extend the search endpoint with limit/offset pagination and sort options. The response now includes total count (from a separate count query) so frontends can paginate properly. - Add Limit, Offset, Sort, Order to SearchParams with Normalize() defaults - Return SearchResponse struct with total/limit/offset metadata - Count query runs alongside results query for accurate totals - Sort options: relevance (default), created_at, updated_at, title - Add --sort, --limit, --offset flags to CLI search command - Update frontend SearchFilters and SearchResponse types - Add TestSearchPagination and TestSearchSorting integration tests * fix: count ref hits in search totals and handle empty pages - Ensure total is never less than actual results when direct ref matches (e.g. "TASK-5") aren't captured by the FTS count query - Handle empty page in CLI output: show "No results on this page" instead of an invalid descending range like "Showing 11-10 of 5" Addresses codex review on PR #123. * fix: paginate ref hits correctly and add sort tie-breaker - Ref hits now occupy slots on page 0 only; FTS limit/offset adjusted so combined results respect the requested pagination contract - On subsequent pages, ref hits are excluded (already shown on page 0) - Add i.id as deterministic tie-breaker to all ORDER BY clauses to prevent duplicate/missing items across paginated pages Addresses codex review on PR #123. |
||
|
|
aef0e2326a |
feat: add collection and field filtering to search API (#122)
* feat: add collection and field filtering to search API Extend the /search endpoint to support scoping by collection slug and filtering by structured field values (status, priority, and generic field.* params). Works on both SQLite FTS5 and PostgreSQL tsvector. - Add Collection and FieldFilters to SearchParams (store layer) - Parse collection, status, priority, field.* query params (handler) - Add SearchFilters type and update api.search() signature (frontend) - Add --collection, --status, --priority flags to CLI search command - Add integration tests for collection, field, and combined filtering * fix: validate field filter keys to prevent SQL injection Reject field filter keys containing special characters before they reach JSONExtractText, which interpolates keys directly into SQL. Keys must match ^[a-zA-Z][a-zA-Z0-9_-]*$ — validation is applied in both the handler and the store layer as defense in depth. Addresses codex review on PR #122. |
||
|
|
c5183d2a4b |
feat: add SSE events for item star/unstar (#121)
* feat: add SSE events for item star/unstar Emit real-time events for multi-tab sync (PLAN-564, TASK-571): - New event types: item_starred, item_unstarred - Emitted from handleStarItem and handleUnstarItem after success - Includes item ID, title, collection, actor, and source - Follows existing publishItemEventWithName pattern * fix: scope star/unstar SSE events to the acting user Star events are user-specific state, not workspace-wide. Changes: - Add UserID field to Event struct for user-scoped events - SSE handler filters events with UserID, only delivering them to the user who triggered the action (multi-tab sync without leaking star actions to other workspace members) - Star/unstar handlers set UserID when publishing events |
||
|
|
f7d93d878d |
feat: add starred items to dashboard (#119)
* feat: add starred items to dashboard Add starred items section to the dashboard (PLAN-564, TASK-569): - Dashboard API: fetches non-terminal starred items for the current user, applies RBAC visibility filtering, returns as starred_items array - TypeScript types: add starred_items to DashboardResponse - Dashboard UI: renders starred items section with card grid, item refs, status pills, and "View all" link to /starred page * fix: cap starred items in dashboard response to 10 Match the same limit applied to active_items, preventing large payloads on the polling dashboard endpoint for users with many starred items. |
||
|
|
844e40f0a9 |
feat: add star/unstar API endpoints (#116)
* feat: add star/unstar API endpoints
Add REST API for item starring (PLAN-564, TASK-566):
- POST /workspaces/{ws}/items/{slug}/star — star item (idempotent, 204)
- DELETE /workspaces/{ws}/items/{slug}/star — unstar item (204 or 404)
- GET /workspaces/{ws}/items/{slug}/star — check star status ({"starred": bool})
- GET /workspaces/{ws}/starred — list starred items (?include_terminal=true)
All endpoints are scoped to the authenticated user, check item visibility
via RBAC/grants, and enrich list responses with parent links and refs.
* fix: enforce RBAC visibility filtering on starred items list
Apply the same collection/item grant filtering used by handleListItems
to handleListStarredItems. Without this, guests or restricted members
could see starred items from collections they no longer have access to.
|
||
|
|
6a552b83ae |
fix: link comment to activity record to prevent duplicate timeline entries (#114)
* fix: link comment to activity record to prevent duplicate timeline entries (#BUG-563) When creating a comment, the handler created a comment (activity_id=NULL) and a separate "commented" activity but never linked them. The timeline dedup logic only filters activities that have a linked comment, so both showed up as separate entries. Fix: create the activity first via logActivityWithMetaReturningID, then pass its ID to CreateComment so buildTimeline correctly deduplicates them. * fix: only link activity ID to comment when activity insert succeeds Address review feedback: CreateActivity assigns an ID before Exec and returns it even on insert failure. Since comments.activity_id has a FK constraint, setting a dangling reference would break comment creation. Now we only set ActivityID when the activity was actually persisted. |
||
|
|
ba01d95111 |
feat: add web UI for TOTP 2FA setup in user settings (#109)
* feat: add web UI for TOTP 2FA setup in user settings Add a Two-Factor Authentication section to the console settings page so users can enable/disable TOTP 2FA from the browser. The backend API already existed (PR #77); this wires up the frontend. - Add 2FA section to console settings with enable/disable flows - Enable flow: QR code + manual secret + verification code input - Recovery codes displayed with copy/download after setup - Disable flow: password confirmation modal - Add totp.setup/verify/disable methods to API client - Add TOTP types (TOTPSetupResponse, TOTPVerifyResponse, etc.) - Add totp_enabled to User type and /auth/me response - Add qrcode npm dependency for rendering otpauth:// URIs Closes TASK-402 * fix: address codex review — separate QR rendering from setup, use clipboard util - Separate QR code rendering from TOTP setup API call so a QR failure doesn't abort setup when manual entry is still available - Use existing copyToClipboard utility with legacy fallback instead of raw navigator.clipboard.writeText |
||
|
|
56adba4b58 |
feat: add invitation management panel for admin console (#107)
* feat: add invitation management panel for admin console Platform-wide view of all pending invitations with search, resend, and revoke. Resend creates a fresh invitation code and sends the email. New admin endpoints: GET/POST/DELETE for /admin/invitations. * fix: check email opt-out on resend, abort on stale delete, reload list Respect unsubscribe preferences before resending invitation emails. Abort resend if the old invitation was already accepted/revoked concurrently. Reload the full invitations list after resend since the row ID changes. |
||
|
|
86451174ad |
feat: add user detail panel with workspace memberships (#106)
* feat: add user detail panel with workspace memberships
New GET /api/v1/admin/users/{id}/workspaces endpoint returning workspace
name, slug, role, and join date. Frontend loads memberships when a user
row is expanded and displays them as a linked list with role badges.
* fix: scope workspace fetch error/loading to active selection
Gate both the catch and finally blocks with a selectedId check so stale
requests from previously selected users don't wipe workspace data or
clear the loading indicator for the current selection.
|
||
|
|
b3af1acd07 |
feat: add last active tracking for users (#105)
* feat: add last active tracking for users Track when users were last active via a throttled update (once per 5 minutes) in the auth middleware. Adds last_active_at column, displays relative time in admin user list with full timestamp on hover. * fix: bound last-active goroutine with 3s context timeout Use a short-lived context for the background TouchUserActivity write so it gets cancelled under DB pressure, preventing goroutine/connection buildup from unbounded background work. |
||
|
|
d968b551b7 |
feat: add account disable/deactivation (#104)
* feat: add account disable/deactivation for admin users Allow admins to soft-disable user accounts without deleting data. Disabled users get a 403 on all authenticated requests, their sessions are invalidated on disable, and they show as visually dimmed with a red "disabled" badge in the admin console. Includes migration for disabled_at column, auth middleware check, disable/enable endpoints with audit logging, and frontend toggle with confirmation dialog. * refactor: auto-discover migrations from embedded filesystem Replace hardcoded migration lists with fs.ReadDir on the embedded FS directories. New migrations are now picked up automatically by filename sort order — no need to manually register them in store.go. * fix: block disabled users at login and capture IDs before async calls Reject disabled accounts in the login handler before session creation, not just in RequireAuth middleware (which exempts auth routes). Also capture selectedId into a local const in all async admin panel functions to prevent stale updates if the selection changes during a request. * fix: enforce disabled check in OAuth and password reset flows, always invalidate sessions Block disabled users in all session-minting paths (OAuth login, password reset) not just password login. Also remove early return for already-disabled users in the disable endpoint so session invalidation always runs, handling retry after partial failure. |
||
|
|
79d7d26a00 |
feat: add admin password reset for other users (#103)
* feat: add admin password reset for other users
New POST /api/v1/admin/users/{id}/reset-password endpoint. When email is
configured, sends a password reset link. Otherwise generates a temporary
password and invalidates existing sessions. Includes audit logging via
new password_reset_by_admin action and frontend UI with confirmation.
* fix: treat session revocation and email send as hard failures
Make session invalidation failure abort the reset instead of silently
continuing, and send the reset email synchronously so delivery failures
are surfaced to the admin caller.
|
||
|
|
f97ab766f5 |
feat: add admin role management (promote/demote users) (#102)
* feat: add admin role management (promote/demote users) Allow admins to change user roles between admin and member from the admin console. Includes safety guards to prevent self-demotion and demoting the last admin, with full audit logging. * fix: make last-admin demotion guard atomic Move the admin count check into the SQL UPDATE itself so two concurrent demotion requests cannot both observe >1 admin and proceed. The conditional UPDATE only demotes when at least one other admin exists, eliminating the TOCTOU race. |
||
|
|
7ca0463e70 |
feat: browser-based CLI authentication flow (#97)
Replace the email/password terminal prompt in `pad auth login` with a
browser-based auth flow. The CLI creates a pending session, prints a URL
the user opens in their browser (works for localhost, remote VPS, or
Pad Cloud), and polls until the session is approved.
- Add CLI auth session endpoints (create, poll, approve)
- Add browser approval page at /auth/cli/{code}
- Rewrite `pad auth login` to use browser flow by default
- Keep `pad auth login --interactive` as email/password fallback
- Add login page redirect param support for post-login bounce-back
- Add SQLite and PostgreSQL migrations for cli_auth_sessions table
Closes PLAN-539, IDEA-404
|
||
|
|
1ba9c91992 |
feat: email unsubscribe for non-transactional emails (#96)
* feat: email unsubscribe for non-transactional emails Add CAN-SPAM compliant unsubscribe support: - New email_optouts table (by email address, not user ID) so uninvited recipients can opt out without an account - HMAC-signed unsubscribe tokens (derived from Maileroo API key) so links work without authentication - GET /api/v1/unsubscribe endpoint with simple HTML confirmation page - Invitation emails now include unsubscribe footer link - Welcome emails accept unsubscribe URL parameter - Before sending invitation emails, check opt-out table and silently skip opted-out addresses (prevents invite spam) - Password reset emails are exempt (transactional, user-initiated) Fixes BUG-256. * fix: hide "Copy invite link" when code is unrecoverable For hashed invitations the plaintext code can't be recovered, so the button was copying a broken URL. Now shows "Sent via email" label instead. Only shows the copy button when join_url or code is available. Fixes BUG-255. |
||
|
|
ac24fb742c |
fix: breadcrumbs show parent item path for child items (#94)
When viewing a child item (e.g. TASK-101 under PLAN-10), the breadcrumb now shows "Home / Plans / PLAN-10 / TASK-101" instead of the flat "Home / Tasks / TASK-101". - Add parent_slug and parent_collection_slug fields to Go Item model - Populate them in both single-item and bulk enrichment paths - Add corresponding TypeScript types - Update breadcrumb nav to show parent collection and parent item when the item has a parent, falling back to the item's own collection Fixes BUG-516. |
||
|
|
1e464ffdac |
fix: apostrophe in slugs, split auto-close, and move navigation (#92)
- Strip apostrophes in slugify() so "Dave's Workspace" becomes "daves-workspace" instead of "dave-s-workspace" (BUG-517) - Use replaceState when navigating after item move to avoid polluting browser history (BUG-538) - Don't auto-close items when split children are done — splitting work out doesn't mean the original is complete (BUG-401) |
||
|
|
b2b4feecb9 |
feat: console navigation, PostgreSQL CI, and operational improvements
- Route root (/) to /console for centralized workspace management - Update TopBar user dropdown with console nav links (workspaces, settings, billing, admin) - Move account settings (profile, password, tokens) from workspace settings to /console/settings - Enhance admin page with email configuration UI and CSRF-protected writes - Add PostgreSQL CI job to GitHub Actions with race detector on main - Add `make test-pg` for local PostgreSQL testing via docker-compose - Expand health/ready endpoint with DB connection pool stats - Increase item number retry limit for high-concurrency environments - Add concurrent store benchmarks and FTS search quality tests - Add AGENTS.md for multi-agent development guidance |
||
|
|
b7808f12a1 |
fix: address Codex review findings for PR #90 (iteration 2)
Update admin frontend to handle new paginated user list response shape
({ users, total } instead of bare array). Add legacy pad_session cookie
fallback to SessionAuth middleware matching validateSessionCookie. Exempt
/api/v1/plan-limits from RequireAuth so billing page can read limits
without authentication.
Co-Authored-By: Claude <noreply@anthropic.com>
|
||
|
|
460d213526 |
fix: address review findings for PR #90 (iteration 1)
Exempt new sidecar endpoints (/admin/stripe-customer-id, /admin/user-by-customer) from RequireAuth and CSRF middleware. Fix OAuth unlink lockout guard that never triggered because PasswordHash is always non-empty. Return total count from admin user list for pagination support. Co-Authored-By: Claude <noreply@anthropic.com> |
||
|
|
93b08b3e1b | fix: allow bootstrap in cloud mode when no users exist yet | ||
|
|
92580905bb |
feat: cloud hardening and security follow-ups (PLAN-503)
Address 11 issues identified during the PLAN-427 security review: Critical/High: - Stripe customer-to-user mapping with indexed lookup (TASK-505) - OAuth provider linking with explicit consent model (TASK-504) - CSRF tokens on admin console mutations (TASK-506) - Rate limiting on cloud admin and OAuth endpoints (TASK-507) Medium: - __Host- cookie prefix for subdomain protection (TASK-510) - Billing portal verifies customer ownership server-side (TASK-515) - Transactional account deletion with rollback (TASK-509) - Streaming data export with 60s timeout (TASK-508) - Migration registration for new columns (TASK-514) Low: - Billing page fetches actual plan limits from API (TASK-511) - Admin user search/filter pushed into SQL with pagination (TASK-512) |
||
|
|
e6f123a4c3 |
fix: address Codex review findings for PR #89 (iteration 2)
- Exempt /admin/plan from RequireAuth and CSRF middleware so the pad-cloud sidecar can call it with cloud_secret body auth - Add X-CSRF-Token header to admin console PATCH requests - Send plan_overrides as a JSON string (not parsed object) to match backend *string decoder expectation - Restrict confirm-only account deletion to cloud mode to prevent password users from bypassing re-auth Co-Authored-By: Claude <noreply@anthropic.com> |
||
|
|
b2ec0a4f55 |
fix: address review findings for PR #89 (iteration 1)
Fix admin limits endpoint returning wrong defaults for pro plan, correct swapped billing page usage numbers, validate expires_at format in plan endpoint, and handle errors properly in admin stats endpoint. Co-Authored-By: Claude <noreply@anthropic.com> |
||
|
|
d0518216c5 |
feat: add cloud infrastructure for hosted Pad (PLAN-427)
Add the foundation for running Pad as a hosted service at app.getpad.dev. Same binary in cloud mode with a thin sidecar for OAuth and Stripe. Cloud mode (PAD_CLOUD=true): - PAD_CLOUD flag with cloud secret for sidecar communication - Account-level billing: plan field on users, CheckLimit enforcement - Free/Pro tiers with configurable limits stored in platform_settings - Three-tier limit resolution: user overrides → DB defaults → hardcoded fallback - Plan enforcement on workspace, item, member, webhook, and token creation Authentication & security: - OAuth login endpoint (POST /api/v1/auth/oauth-login) with cloud secret gate - Verified email requirement for OAuth, 2FA bypass protection - Cloud secret rotation support (comma-separated keys) - TOTP secret encryption at rest (AES-256-GCM via PAD_ENCRYPTION_KEY) - Rate limiting on OAuth login endpoint - Bootstrap disabled in cloud mode - Password max length enforcement (128 chars) - Config file written with 0600 permissions Admin & billing: - Admin user management API (list, detail, update plan/overrides) - Configurable plan limits API (GET/PATCH /api/v1/admin/limits) - Platform stats endpoint - Admin plan endpoint for sidecar to set user plans - GDPR: account deletion and data export endpoints Console UI (cloud mode only): - /console — workspace list with owned/shared sections - /console/new — create workspace wizard with slug preview - /console/settings — profile, password, API tokens - /console/billing — plan status, upgrade/manage links - /console/admin — user management, plan overrides, limits editor - OAuth buttons (GitHub/Google) on login page in cloud mode Auto-create default workspace on signup in cloud mode. Migration 035: plan, plan_expires_at, stripe_customer_id, plan_overrides on users. |
||
|
|
94d35509a4 |
feat: share links with hardened security, anonymous access, and analytics (#88)
* feat: share links with hashed tokens and /s/{token} route
Add share_links and share_link_views tables with CRUD API and
anonymous resolution route (TASK-421).
Data model:
- share_links: token_hash (SHA-256), target_type/id, permission,
password_hash, expires_at, max_views, require_auth, view tracking
- share_link_views: per-view records with fingerprint/user tracking
Token security:
- 192-bit entropy (crypto/rand), URL-safe base64 encoding
- SHA-256 hashed at rest, raw token returned only once on creation
- Generic 404 for invalid tokens (no info leakage)
- /api/v1/s/ exempt from auth middleware for anonymous access
API endpoints:
- POST /items/{slug}/share-links — create item share link
- POST /collections/{coll}/share-links — create collection share link
- GET /items/{slug}/share-links — list share links for item
- GET /collections/{coll}/share-links — list for collection
- DELETE /share-links/{id} — revoke share link
- GET /s/{token} — resolve share link, return shared content
D8: Anonymous users are ALWAYS read-only. View count and unique
viewers tracked on each resolution.
* feat: anonymous share page + share link management UI
Add minimal-chrome share link viewer page and share link CRUD in
the share dialog (TASK-422 + TASK-425).
Share page (/s/{token}):
- New SvelteKit route at /s/[token] for anonymous viewing
- Renders item (title, fields, markdown content) or collection
(name, item list) with no app chrome (no sidebar/topbar)
- Handles require_auth links with "Sign in to view" prompt
- Root layout bypasses auth checks for /s/ routes
- "Powered by Pad" footer
Share dialog updates:
- "Share links" section below existing grants
- Create/list/revoke share links for items and collections
- Copy-to-clipboard for share URLs
- Newly created links highlighted with "only shown once" notice
- View count and auth-required badges
API client:
- ShareLink type added
- shareLinks.* methods for CRUD
- share.get(token) for anonymous resolution
* feat: share link constraints + view analytics
Add password protection, expiry, max views, and view history
endpoints for share links (TASK-423 + TASK-424).
Constraints (TASK-423):
- CreateShareLink accepts ShareLinkOptions: password, expires_at,
max_views, require_auth, restrict_to_email
- Password hashed with bcrypt, verified on /s/{token} resolution
- Password-protected links return {require_password: true} prompt
- Expiry and max_views already validated by ValidateShareLink
Analytics (TASK-424):
- GET /share-links/{id}/views returns view history with fingerprint,
user ID, and timestamp
- Response includes total_views, unique_viewers, last_viewed_at
- View history stored per-view in share_link_views table
* fix: harden share links — XSS, access control, data leakage, and UX gaps
- Sanitize rendered markdown with DOMPurify before {@html} injection (XSS)
- Force require_auth=true when restrict_to_email is set (access bypass)
- Reject malformed non-empty JSON bodies with 400 instead of failing open
- Return public DTOs on share endpoints to prevent leaking internal IDs,
creator info, assignees, schemas, and other sensitive fields
- Enforce max_views atomically via conditional UPDATE to prevent races
- Fix collection share rendering: read items from top-level response key
and map ref/status fields correctly
- Add password prompt UI and X-Share-Password header support so
password-protected links can actually be unlocked by the frontend
* fix: follow-up hardening for share links
- Sanitize catch fallback in rendered markdown (XSS edge case if marked throws)
- Remove query-string password fallback; accept only X-Share-Password header
to avoid leaking passwords in logs, browser history, and referrers
- Return 500 on ListItems DB failure instead of swallowing as empty collection
- Normalize restrict_to_email with ToLower/TrimSpace on create and compare
- Fix malformed JSON check for chunked bodies (ContentLength == -1)
by checking for io.EOF instead of ContentLength > 0
- Remove internal share_link.id from public DTO responses
- Use clientIP(r) helper for consistent fingerprinting instead of raw
X-Forwarded-For which is spoofable and includes port in RemoteAddr
- Distinguish DB errors from not-found in share link delete handler
* fix: final hardening pass for share links
- Move auth/email gate before password check to prevent unauthenticated
callers from probing passwords and burning bcrypt CPU
- Wrap view recording (counter increment, unique-viewer accounting, view
insert) in a single transaction so a failed insert rolls back the
consumed view count instead of silently losing it
- Add X-Share-Password to CORS AllowedHeaders so cross-origin
deployments can send the custom header without preflight rejection
- Validate expires_at (RFC3339) and max_views (> 0) on share link
creation; return 400 for invalid constraints instead of creating
immediately-unusable links
- Cap view-history endpoint limit to 1000 to prevent unbounded queries
|