mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-22 02:23:46 +00:00
504d348917c2fb8ed2c139bbbc352e07fccae19a
7 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
afe721d202 |
feat(cli): add Cloud mode to pad init, drop Docker option (TASK-837, TASK-838) (#272)
Merging despite Go (PostgreSQL) red — those failures (TestListItems_FTS_HyphenatedSearchTerm/task-five + TestAdminBillingStats_SidecarSidecarError_DegradesToLocalOnly TempDir cleanup race) are pre-existing on main and tracked in BUG-842. Codex reviewed in 3 rounds (round 1 clean → round 2 found a real semantic bug → fix → round 3 clean). Tests, vet, and lint all green; remaining check failures are documented pre-existing. |
||
|
|
6cda2da48d |
feat(billing): cancel Stripe customer on account delete (TASK-690) (#227)
* feat(billing): cancel Stripe customer on account delete (TASK-690) Parent: PLAN-645. Pair with pad-cloud PR #12. * fix(billing): abort on all non-200 per Codex review (round 1) * fix(billing): env wiring + docstrings + partial_delete test per Codex review (round 2) * fix(compose): wire cloud env vars from .env per Codex review (round 3) |
||
|
|
062eef41b2 |
docs: architecture guide + full .env.example + gitattributes + Makefile note (TASK-687) (#222)
Grouped nice-to-haves called out in the pre-launch audit. 1. docs/architecture.md — new contributor-focused architecture doc. CLAUDE.md covers the same ground but is agent-oriented; this is the human companion. Covers backend layout, request flow, frontend / data model / CLI↔daemon model / agent integration / testing. 2. .env.example — extended to document every PAD_* variable in docs/deployment.md (core, database, real-time events, security, email). Existing Postgres/Redis + encryption secrets kept at the top; new variables grouped by concern with inline comments and safe defaults commented out. 3. .gitattributes — normalize LF line endings repo-wide, mark binary assets, and flag web/build + web/.svelte-kit as generated so they don't pollute GitHub linguist stats or PR diffs. 4. Makefile — CAUTION comment on `make install` noting that the `killall -9 pad` step is system-wide; anyone else's pad daemon on the same machine gets killed too. Designed for single-developer local setups; not for shared hosts. Parent: PLAN-644. |
||
|
|
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).
|
||
|
|
6f468d37b6 |
fix(config): auto-generate PAD_ENCRYPTION_KEY on first run (TASK-668) (#189)
* fix(config): auto-generate PAD_ENCRYPTION_KEY on first run (TASK-668)
store/encryption.go silently accepted an empty key and stored TOTP
seeds in plaintext; cmd/pad/main.go only logged a WARN. Operators who
never saw the warning (or saw it and ignored it) ran for months with
sensitive data at rest in the clear.
Change: encryption is now mandatory. Resolution order inside
Config.EnsureEncryptionKey:
1. PAD_ENCRYPTION_KEY env var (EncryptionKeySource = "env").
2. encryption_key in config.toml (source = "config").
3. <DataDir>/encryption.key file (source = "file").
4. Generate a fresh 32-byte AES-256 key, persist it to the file
above with 0600 permissions, continue (source = "generated").
Generation step never fails silently — mkdir + write errors propagate
out of main.go and abort startup.
main.go:
- drop the "if cfg.EncryptionKey != '' { enable } else { warn }" fork.
- call cfg.EnsureEncryptionKey(), fail startup on error, log at WARN
when a key is freshly generated so operators notice the new file.
Tests (internal/config/encryption_key_test.go):
- generates when missing (file permissions 0600, 32-byte key).
- loads existing file (strips trailing newline).
- respects already-configured values (no file write).
- idempotent across restarts (same key across two Config objects
sharing a DataDir).
Parent: PLAN-643 (OSS Security Hardening).
* fix(config): refuse to auto-generate key in clustered deployments per Codex P1
Codex caught that auto-generating a per-process key on a Postgres-
backed multi-replica deployment would give each replica its own key —
cross-instance decryption of shared DB rows would fail with GCM auth
errors.
Change: EnsureEncryptionKey now takes an allowGenerate bool. main.go
passes (dbDriver != 'postgres'): single-instance SQLite deployments
get the zero-config auto-generation path; Postgres deployments must
set PAD_ENCRYPTION_KEY explicitly. Operators who DO share a volume
across replicas can pre-seed the file and it still loads (the
generate step is the only thing gated).
Tests:
- TestEnsureEncryptionKey_RefusesToGenerateWhenClustered — allowGenerate=false
+ no existing file → error, no file written.
- TestEnsureEncryptionKey_ClusteredWithPreSeededFileStillLoads — the
file path works in clustered mode when the file is already present.
- Existing idempotency test updated to exercise the mixed case (first
boot generates, second boot loads with allowGenerate=false).
* fix(config): atomic encryption key file creation per Codex P2
Codex caught that the check-then-write sequence for encryption.key had
a race: two processes starting together could both pass the os.ReadFile
IsNotExist check, generate different keys, and race the write.
Whichever process wrote first would end up with an in-memory key that
no longer matched the persisted file, and future restarts of THAT
process would decrypt with the 'wrong' key.
Switch to os.OpenFile with O_CREATE|O_EXCL: on EEXIST we re-read the
file and converge on whichever key won the race. Every racing process
ends up with the same key or a clear startup error.
Test: TestEnsureEncryptionKey_ConcurrentStartIsRaceSafe fires 16
goroutines at a shared DataDir and asserts they all observe the same
key. Also runs clean under -race.
* fix(config): fully-written key guaranteed via temp+hardlink per Codex P2
Codex caught that O_CREATE|O_EXCL + ReadFile-on-EEXIST still had a
window where a loser could read an empty/partial file between the
winner's create and its first write. Hex/length validation would then
fail startup with a confusing error.
Switch to temp-file + os.Link:
1. Write the full key to a uniquely-named temp file (fully closed).
2. os.Link(temp, keyPath) atomically creates the final file as a
hardlink to the complete temp inode. EEXIST means a loser; the
file they'd read is another process's already-complete temp.
3. defer os.Remove(tmpPath) cleans up in every path.
The race-safety test now also covers the 'read partial' case
implicitly — if any goroutine loaded an empty/partial key the hex
decode in main.go would fail in production; the test asserts all 16
goroutines observe the same non-empty key.
* fix(config): reject world/group-readable encryption.key per Codex P2
Codex flagged that the file-load path blindly accepted any mode on
encryption.key. On a multi-user host, a pre-seeded file chmod'd to
0644 would hand the AES key to every local user, defeating the whole
purpose of encrypting TOTP seeds at rest.
Stat the file and reject any mode where group or other bits are set
(0077 mask). Error message points the operator at the fix (chmod 600).
Skipped on Windows where Unix permission bits aren't enforced.
Test: TestEnsureEncryptionKey_RejectsWorldReadableFile pre-seeds the
file at 0644 and verifies startup fails with the chmod hint.
* fix(config): always allow key auto-gen; warn on Postgres per Codex P1
Codex caught that gating auto-generation on 'not postgres' broke the
first-boot experience for every Postgres deployment that wasn't already
provisioning PAD_ENCRYPTION_KEY — which includes our own
docker-compose.yml and deploy/k8s/configmap.yaml. Server would exit
with 'encryption key required' before even starting.
Revert the gate: EnsureEncryptionKey(true) always, for every driver.
In exchange, log a WARN specifically on Postgres when we generate a
key, pointing operators at the multi-replica concern.
Trade-off accepted: single-instance Postgres just works; multi-replica
operators get a visible warning and clear failure mode (GCM auth
errors on first cross-replica read) if they don't act on it. Better
than a startup crash for the single-replica majority.
* fix(config): Postgres requires explicit PAD_ENCRYPTION_KEY; provision it in deployments
Codex was right twice — both concerns are real, and this commit
resolves them together:
1. Restore the Postgres gate: EnsureEncryptionKey(false) when
dbDriver == "postgres". Multi-replica deployments must share a
key; auto-generating per pod would fail cross-replica decryption.
2. Update the shipped Postgres deployments to provision a shared
PAD_ENCRYPTION_KEY so first-boot works out of the box:
- docker-compose.yml: PAD_ENCRYPTION_KEY via ${VAR:?err} shell
substitution (fails "docker compose up" with a clear message
if missing, matching the POSTGRES_PASSWORD pattern).
- .env.example: document PAD_ENCRYPTION_KEY as REQUIRED on
Postgres with an "openssl rand -hex 32" hint.
- deploy/k8s/secret.yaml: add PAD_ENCRYPTION_KEY with a
CHANGE_ME placeholder, explain why the replicas: 2 deployment
requires a shared key.
SQLite deployments continue to auto-generate on first boot (the
TASK-668 happy path), so single-user installs stay zero-config.
|
||
|
|
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). |
||
|
|
5fffee2b9e |
fix(docker): bind to 127.0.0.1 + require POSTGRES_PASSWORD (TASK-661) (#174)
* fix(docker): bind to 127.0.0.1 + require POSTGRES_PASSWORD (TASK-661)
A fresh Docker install previously published 7777 on 0.0.0.0 with a
hardcoded pad:pad Postgres credential. The bootstrap endpoint is
reachable until the first admin is created, so this combination lets
anyone who can route to the host claim the instance — and with M5's
X-Forwarded-For spoof (fixed in TASK-660) chained with the loopback
bootstrap check, it became a full takeover.
Changes:
- docker-compose.yml: publish "127.0.0.1:7777:7777" by default, with a
PAD_BIND_ADDR override for operators who intentionally want LAN
access. Require POSTGRES_PASSWORD via ${VAR:?err} so docker compose
refuses to start when it's unset — can't silently inherit a weak
default credential.
- docker-compose.prod.yml: drop the "change-me-in-production"
placeholder; require the same env var as the base file.
- .env.example: new file documenting POSTGRES_PASSWORD (required),
PAD_BIND_ADDR, REDIS_PASSWORD, PAD_CLOUD_SECRET, PAD_ENCRYPTION_KEY,
PAD_TRUSTED_PROXIES with generation instructions.
- README.md: add a Docker Compose section covering the .env workflow
and the loopback-default → LAN override.
Parent: PLAN-643 (OSS Security Hardening).
* fix(docker): use libpq keyword=value DSN to avoid URI-encoding the Postgres password per Codex P1
Passwords produced by 'openssl rand -base64' often include '/', '+', or ':'
which are reserved in URI userinfo. Injecting them into postgres://user:PASS@...
breaks sql.Open. Switch PAD_DATABASE_URL to the libpq keyword=value form
(host=... password=... dbname=...) where the password is parsed as a single
token regardless of special characters.
Also teach pgDbnameFromURL to parse both DSN shapes so 'pad db backup/restore'
still shows the correct database name in its confirmation prompt.
|