← Back to Blog

Day 74: The Saturday Hardening Pass

Saturday. The kind of day where the commit list reads as a checklist rather than a story. Every individual change is small enough that it could have shipped on its own; collectively they're the answer to a class of question Pinchy is starting to be asked — the procurement question, the security-questionnaire question, the what does your auth model actually do question. None of these were broken yesterday. Today they're stronger.

CSRF, Origin, and the Headers Behind a Reverse Proxy

PR #235 lands an Origin/Referer CSRF gate on every state-changing API route. Until today, Pinchy relied on Better Auth's session cookie semantics — same-site, secure, HTTP-only — for cross-origin protection. That's the right baseline. The new gate is the second layer: every POST, PATCH, PUT, DELETE verifies that the request's Origin (or, falling back, Referer) matches the configured site host. A request whose Origin is missing or unrecognised gets a 403 at the edge before any handler runs.

Two follow-ups landed in the same session because they had to. First, /api/internal/* — the routes the gateway and its plugins call back into Pinchy with — gets exempted from the gate, because those callers don't carry a browser Origin at all and were never the threat model the gate was protecting against. The exemption is path-prefixed, not header-sniffed, because a header-based exemption is exactly the kind of thing an attacker would craft. Second, the X-Forwarded-Host parser learned to handle multi-hop values: a request that's been through two reverse proxies has a comma-separated list, and we should be looking at the first entry (the original client-facing host) not the last. The setup-wizard tests started sending an explicit Origin header so the integration runs survive the new gate.

Auth Hardening

PR #234 raises the password policy to 12 characters and adds a breach-list check — passwords known to be in published credential dumps are rejected at registration and password-change time. The schema lives in one canonical place now (PR #234's matching refactor) and is shared across the registration form, the password-change form, and the invite-claim form, so a future tightening is one change in one file rather than three. The invite-claim integration test got updated to use a fixture password that satisfies the new policy.

Better Auth's rate limits — which had been on with the library defaults — got hardened with explicit customRules per path. The defaults are reasonable for a typical application; Pinchy's auth surface is unusual enough (lots of invite flows, password resets, organisation-scoped logins) that path-specific rules are the right shape. Login, register, password-reset, and invite-claim each get their own rate-limit identity instead of sharing one global bucket.

PR #228 finishes the centralisation: every API route's auth and role check now goes through withAuth or withAdmin, instead of each route doing its own session-lookup-then-check pattern. There were a handful of routes that had been getting it slightly wrong — checking the session existed but not its role, or checking the role but not whether the user was banned — and the centralisation eliminates the class of mistake. The matching review-follow-up commit landed alongside.

Audit Stops Being Fire-and-Forget

PR #231 is the one referenced obliquely yesterday: appendAuditLog().catch(() => ) patterns get replaced with await appendAuditLog(...) for the routes where the emission must complete before the response, and a new deferAuditLog helper for the genuinely-deferred cases. Fire-and-forget sounds harmless until you realise that an audit emission that silently fails leaves no record that the action happened — exactly inverting the property the audit log exists to provide.

While we were in the audit code, two more fixes. PR #225 fixes the audit emission for provider deletion — a cascade-delete that previously logged only the provider record being removed, now logs the full cascade detail (which agents lost their tool permissions, which secrets got revoked) so the trail of what actually happened is reconstructable. PR #238 redacts email addresses from audit-detail JSON before it lands in the log. The redaction was already happening at the audit-export stage, but having raw emails in the underlying rows was making backups and replication carry data they shouldn't.

Two 1000-Line Files Get Split

PR #233 splits openclaw-config from a 1097-line module into focused sub-files: one for the writer, one for the reader, one for the secrets bundle (which is itself a new abstraction — provider and gateway secrets get pulled into a single bundle so the redaction and migration paths only have one place to look), one for the diff-and-merge logic, one for the inotify watcher. Same lines of code, dramatically more navigable. PR #230 does the same for agent-templates: a 1395-line file gets split into modules per template family, with the original ordering preserved so the pre-built template list still renders the same way to existing users.

One small fix that fell out of the refactor: plugins.allow order is now preserved through writes. The previous shape was sorting it alphabetically on every write, which was triggering the agent-create cascade because OpenClaw's read of the file then disagreed with the in-memory shape on the order. Yesterday's #193 fix removed the cascade trigger via the RPC path; today's commit removes it via the file-format path too, so the inotify fallback is also cascade-free.

SSRF Guard Round Two

The web-fetch SSRF guard from Day 64 picked up two follow-ups today. First, IPv4-mapped IPv6 addresses (::ffff:10.0.0.1) now get blocked the way their plain IPv4 form would; the guard had been resolving the IPv6 form and missing the embedded private range. Second, the resolved IP is now pinned for the lifetime of the request: instead of resolving the hostname once for the guard check and then letting the HTTP client resolve it again at request time (a classic DNS-rebinding TOCTOU), the request goes out against the pre-resolved IP with the Host header set to the original hostname. The dead try/catch that used to swallow the resolver's errors got removed in the same pass.

RBAC on Telegram, Real-DB Tests, and Other Threads

PR #226 applies the workspace RBAC visibility filter to the /api/telegram-bots endpoint — the one route where it had been missed when RBAC went in, so a user without channel-management permissions could see the list of bots even if they couldn't act on them. The pairing UX got a related fix to make sure the pairing pending indicator survives the new filter, and the per-bot setting fetch got parallelised because the previous serial-fetch pattern made the page noticeably slower with more than ~5 bots.

PR #229 lands a real-database Vitest runner: integration tests now run against a Postgres container started by the harness rather than a mocked DB or a sqlite fallback. The first suite to convert is the invite-claim flow — exactly the place where mocked-DB tests had been hiding subtle bugs (transactions, cascading FKs, on-conflict semantics) that only show up against the real engine. The CI job for it landed alongside, with documentation in docs/contributing describing the pattern so the next suite to convert has the recipe. Postgres for that job comes from mirror.gcr.io for the same Docker Hub rate-limit reasons the e2e job already uses the mirror.

PR #237 serialises concurrent Google OAuth refresh attempts: a request burst against an integration whose token had expired was, until today, racing several concurrent refresh calls and ending up with one valid token and a few revoked ones. The serialisation lock is per-integration; refresh requests beyond the first wait on the in-flight one and reuse its result.

Day 74

The list is long today and the throughline is the one this whole week has been pointing at: when the next people who run Pinchy aren't from the team that built it, the things that matter are the things on lists. CSRF gates and password policy and audit-trail completeness and SSRF guards aren't features anyone gets excited about. They're the floor; their absence is what would make a serious evaluator put the product down. Today the floor is several inches higher than it was yesterday. The next stretch goes back to the visible work — release notes, screenshots, the v0.5.0 announcement — but the floor had to be done first.

← Day 73: A Chat That Stops Claiming Green Day 75: The Bonjour Watchdog and Other Ghosts →

Pinchy is open source and ready to deploy. Clone the repo, run docker compose up, and your first agent is live in minutes.