fix: Security 24 vulnerabilities across auth, rate limiting, and uploads - #783
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: aa5293a247
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const parsedCursor = params.cursor ? Number(params.cursor) : undefined; | ||
| const cursorValue = | ||
| parsedCursor !== undefined && | ||
| Number.isSafeInteger(parsedCursor) && | ||
| parsedCursor >= 0 | ||
| ? Math.min(parsedCursor, MAX_SEARCH_OFFSET) | ||
| : undefined; |
There was a problem hiding this comment.
Restrict the 10,000 cap to relevance offsets
Once core_search_index.id exceeds 10,000, this also clamps keyset cursors used by the newest and oldest branches. For example, a first page ending at ID 25,000 requests the next page with that cursor, but the query is changed to ID 10,000, silently skipping or repeating thousands of results. Preserve the validated ID for keyset pagination and apply MAX_SEARCH_OFFSET only inside the relevance branch.
Useful? React with 👍 / 👎.
| if ( | ||
| INSECURE_CRON_SECRETS.includes(cronSecret) && | ||
| !CONFIG.node_development | ||
| ) { |
There was a problem hiding this comment.
Make the development exception match the dev command
With the repository's normal pnpm dev flow, apps/api/package.json runs tsx watch src/index.ts without setting NODE_ENV, so CONFIG.node_development is false. Because both the built-in fallback and the .env.example placeholder are in INSECURE_CRON_SECRETS, a fresh development checkout now rejects every scheduled cron request with 403 despite this explicit development exception; set the environment in the dev command or use a development signal that the shipped workflow actually provides.
Useful? React with 👍 / 👎.
A security review of the API surface. Each fix has tests covering the behaviour that was wrong. Access control - The admin user-update route guarded only the primary role, so `secondaryRoleIds` could attach a root role without `can_edit_admin` - `loadStaffPermissions` reads primary and secondary roles alike, so an administrator holding `users:can_edit` could make themselves root. Every role being assigned now goes through the guard, which also recognises root and moderator-granting roles. - The admin queue list selected every column, including `payload` - for `send-email` jobs the fully rendered message, live password-reset links included - for anyone with `queue:can_view`. It now selects the columns its response schema declares. - `POST /admin/notifications/send` required only an admin session, letting any restricted administrator push arbitrary in-product notifications to any user. Gated on `dashboard:can_edit`, like its sibling widget route. Credentials - Password-reset tokens were written to the database in plaintext (the hashing helper existed and was never called), so any read of the table was account takeover. Only the digest is stored now, and a completed reset revokes the user's sessions. - `CRON_SECRET` falls back to a constant published in this repository, so an install that never set it ran every cron job for anyone. Refused outside development, along with the scaffolded `.env.example` placeholder; the comparison is timing-safe and the `Bearer` prefix is matched rather than substring-replaced. - Sign-in answered "no such email" without hashing, timing-disclosing which addresses hold accounts. Both paths now derive a key. - `verifyPassword` continued after rejecting and threw a 500 on a malformed stored hash; the salt widens to 16 bytes for new hashes. Rate limiting and identity - The limiter was registered before the middleware that set `ipAddress`, so every request in the deployment shared one bucket named `undefined` - no per-client throttling, and a global kill switch at 80 requests a minute. Its unit test set `ipAddress` first, the opposite of the real wiring. - The client address was read from the first of sixteen client-settable headers, so any caller could choose their own bucket and their own line in the audit trail. Resolution is socket-based unless `trustProxy` says how many proxies are in front, and counts from the right so a forged chain is stepped over. Runtimes with no connection info now warn. Uploads and transport - The stored extension came from the client filename while the type came from the client `Content-Type`, so a file accepted as `image/gif` could be written as `.html` and served as a page from the app's own origin. The extension is now bound to the validated media type, and the uploads mount sends `Content-Security-Policy: sandbox` and `nosniff`. - The `/api/ws` handshake is cookie-authenticated but validated no Origin, and `csrf()` does not cover a GET - any site could open a socket as a visiting user. Added an origin check. - Auth cookies stated no `SameSite`; set to `Lax` explicitly. - The reCAPTCHA token was interpolated unencoded into the verification URL alongside the secret key; both now travel in a form-encoded body, and a missing secret key fails closed. - Swagger UI and the OpenAPI document were served unconditionally, publishing the whole attack surface. Off in production unless asked for. - Removed `POST /users/test`, an unauthenticated debug route that wrote a log row per call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QtoqPREn6SsE5M4oFnQH5
Canonicalising a locale prefix strips it off the front of the path, so `/en//evil.example` became `//evil.example` - which is not a path but a protocol-relative URL, and a browser following that `Location` reads everything after the two slashes as a host. The site answered a request for one of its own URLs with a permanent redirect to somebody else's: a phishing link genuinely hosted on the real domain, and a way past any allowlist that trusts a same-origin-looking link. Leading slashes now collapse to one, backslashes included - browsers treat those as separators here even though the URL parser does not. The new test fails on all five payloads without the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QtoqPREn6SsE5M4oFnQH5
…e session cache on a role change
Three findings from a second pass over the same review.
- `GET /admin/roles/{id}` was reachable on an admin session alone. `list` is
deliberately ungated - a role picker has to work for an administrator who
cannot open the roles screen - but that reasoning does not extend to one
role's full record, which only the edit screen reads. Gated on
`roles:can_view`, which `can_edit` already depends on, so nobody who could
open the screen loses access. The parity test's expectations move with it.
- The Postgres search adapter turned the client's `cursor` into the query's
`OFFSET` with no validation: `Number("abc")` is `NaN`, which Postgres
rejects as a 500 rather than a bad request, and a large one is a full scan
anybody can ask for by editing a URL. Now a checked integer, capped.
- A role change expired the staff-permission cache but not the session
cache, and `resolveStaffPermissions` reads the primary role off the cached
user object - so recomputing reached the same answer it had just thrown
away. Somebody demoted out of an administrator role kept its powers for
about a minute after the AdminCP said otherwise. Both caches now go, on
both write paths (a request changing only roles takes the second one).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QtoqPREn6SsE5M4oFnQH5
…, and the rest of the second sweep Findings from the audit's completeness pass, each verified against the code. - Nothing bounded a request body anywhere in the stack, and `POST /sign_in` buffers its JSON and then runs scrypt on it unconditionally - so an unauthenticated caller chose how much memory and CPU to spend. Added a 25 MB default with `maxBodySize` to move it. Uploads keep their own per-field `maxBytes`; this is the outer wall. - `SessionModel.getUser` resolved the device before it knew the session was real, and resolving created one. Any request carrying a made-up `vitnode_auth` cookie therefore inserted a `core_sessions_known_devices` row - unauthenticated, one per request, unbounded. Split the model into `getExistingDeviceId` (a read, used by session resolution, where a missing device already means no session) and `getOrCreateDeviceId` (sign-in and sign-up, where minting one is the point). - The public search endpoint passed `Number(authorId)` and `new Date(from)` straight into the query builder, so `?authorId=abc` became `NaN` and Postgres answered with a 500 - which also wrote a `core_logs` row. A filter that cannot be parsed is now a filter that was not asked for. - The Discord SSO adapter never read Discord's `verified` flag, so an unconfirmed address could open an account keyed on it. Google already refuses this; Discord now matches. - The dev docker-compose files published Postgres and Redis on every interface with a default password of `root`. Bound to loopback. - Both reference apps read `TRUST_PROXY` for the hop count. The web app needs it most: its Start mount hands Hono a bare `Request` with no socket behind it, so without it every visitor resolves to the same fallback address and shares one rate-limit bucket. Documented in both `.env.example` files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QtoqPREn6SsE5M4oFnQH5
Covers all 24 findings fixed across the four commits on this branch, with the attack path and the reasoning for each, plus eight issues reported and deliberately left alone because they need a product or deployment decision rather than a patch. Two things the document is careful about. It records what was checked and found sound, which is half of what a review is for - and several of those were places the obvious guess would have been wrong. And it is explicit about its own limits: no running instance, no database, no browser, no CVE scan, and machine verdicts that ran against already-patched code, so their "refuted" is not evidence of anything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QtoqPREn6SsE5M4oFnQH5
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d3ff20fc98
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| @@ -0,0 +1,128 @@ | |||
| --- | |||
| title: Navigation | |||
There was a problem hiding this comment.
Preserve redirects for moved documentation pages
When a reader follows a previously published URL that this commit moves—for example, /docs/dev/tanstack/navigation, whose replacement is this new /docs/dev/routing/navigation page—the old source has been deleted and getDocsPage performs only an exact lookup before returning a 404. A repo-wide search finds no redirect mapping for these renamed pages, so existing bookmarks and inbound links break; retain permanent redirects from the old documentation paths.
AGENTS.md reference: AGENTS.md:L75-L75
Useful? React with 👍 / 👎.
| description={t("errors.404.desc")} | ||
| title={t("errors.404.title")} |
There was a problem hiding this comment.
Render the 500 translations for server errors
When the router invokes defaultErrorComponent for an application error, this component displays the errors.404 title and description despite rendering status code 500. Since the locale files define dedicated errors.500 strings, genuine server failures are misleadingly presented as missing pages; use those 500 translations here.
Useful? React with 👍 / 👎.
d3ff20f to
8e13c66
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8e13c66025
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } | ||
|
|
||
| // Bun: `server.requestIP(request)`. | ||
| const server = (env as { server?: unknown }).server; |
There was a problem hiding this comment.
Read Bun's server directly from the Hono environment
In the generated Bun runtime, packages/create-vitnode-app/copy-of-vitnode-app/api-bun/src/index.ts exports app.fetch directly, so Bun passes its Server object as Hono's c.env; it is not nested under c.env.server. Consequently direct Bun deployments with trustProxy unset fall through to 127.0.0.1, putting every caller in the same rate-limit bucket. Probe requestIP on c.env itself and invoke it with the server as its receiver.
Useful? React with 👍 / 👎.
| ): string => { | ||
| const socket = socketAddress(c); | ||
|
|
||
| if (!trustProxy) return socket ?? UNKNOWN_CLIENT_IP; |
There was a problem hiding this comment.
Wire proxy trust into the generated application templates
When a generated application is deployed behind a reverse proxy, this fallback uses the proxy socket—or 127.0.0.1 for the single-app bridge—for every request unless trustProxy is configured. A repo-wide search of packages/create-vitnode-app/copy-of-vitnode-app finds no trustProxy or TRUST_PROXY wiring in either generated API config or its .env.example, so freshly scaffolded production apps regress to one globally exhaustible rate-limit bucket. Add the same environment-backed configuration and documentation used by the reference apps.
Useful? React with 👍 / 👎.
| and( | ||
| eq(core_users_forgot_password.userId, userId), | ||
| eq(core_users_forgot_password.token, token), | ||
| eq(core_users_forgot_password.token, hashedToken), |
There was a problem hiding this comment.
Handle reset tokens issued before this rollout
If a password-reset email was issued before deployment and its 30-minute window is still open, the existing database row contains the raw token, while this lookup now compares only its SHA-256 digest. Every such valid link therefore starts returning 400 immediately after rollout. Version or migrate outstanding rows, or provide a bounded compatibility path for pre-rollout tokens.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bef98a7033
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| parsedCursor !== undefined && | ||
| Number.isSafeInteger(parsedCursor) && | ||
| parsedCursor >= 0 | ||
| ? Math.min(parsedCursor, MAX_SEARCH_OFFSET) |
There was a problem hiding this comment.
Reject relevance cursors past the cap
Independently of the keyset-pagination problem, a relevance search with more than 10,000 matches can return hasNextPage: true at offset 10,000 and an endCursor greater than 10,000. Feeding that cursor back is silently clamped here to 10,000, so the client receives the same page and cursor indefinitely. Reject cursors beyond the cap or stop advertising a next page once the cap is reached.
Useful? React with 👍 / 👎.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Description
What?
This PR fixes 24 security vulnerabilities identified in a comprehensive security review of the VitNode codebase. The issues range from critical privilege escalation and world-runnable cron jobs to high-severity rate limiter failures and plaintext password reset tokens.
Critical fixes:
secondaryRoleIdsfield bypass (finding Block space for name #1)High-severity fixes:
undefined, creating a single global bucket (finding #3)Medium-severity fixes:
Low-severity fixes:
Why?
The security review identified systemic issues that could allow attackers to escalate privileges, bypass rate limiting, access sensitive data, and perform denial-of-service attacks. These fixes address the root causes rather than symptoms, with particular attention to:
All fixes include comprehensive test coverage. No database migrations required; no existing credentials invalidated.
Test Plan
cron-auth.middleware.test.tscovering secret comparison and insecure defaultsassert-edit-user-permission.test.tscovering role assignment guardsclient-ip.test.tscovering IP resolution with various proxy configurationsdevice.test.tscovering device creation guardspassword.test.tscovering hash verification and salt handlingtanstack/i18n/request.test.tscovering redirect validationwebsocket-origin.middleware.test.tscovering origin validationupload.test.tsandadmin-permission-parity.test.tshttps://claude.ai/code/session_014QtoqPREn6SsE5M4oFnQH5