Skip to content

fix: Security 24 vulnerabilities across auth, rate limiting, and uploads - #783

Merged
aXenDeveloper merged 8 commits into
canaryfrom
claude/vitnode-security-review-5ly6gw
Sep 6, 2026
Merged

fix: Security 24 vulnerabilities across auth, rate limiting, and uploads#783
aXenDeveloper merged 8 commits into
canaryfrom
claude/vitnode-security-review-5ly6gw

Conversation

@aXenDeveloper

Copy link
Copy Markdown
Owner

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:

  • Privilege escalation to root via secondaryRoleIds field bypass (finding Block space for name #1)
  • Cron jobs runnable by anyone using published default secret (finding #2)

High-severity fixes:

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:

  1. Privilege escalation: Secondary roles now go through the same guards as primary roles
  2. Rate limiting: IP address resolution moved before rate limiter middleware
  3. Cron security: Production refuses insecure defaults; development still works out of the box
  4. Password reset: Tokens now hashed; old sessions revoked on password change
  5. Upload safety: File extensions validated against MIME type to prevent XSS
  6. WebSocket security: Origin validation added to prevent CSWSH attacks
  7. IP handling: Dedicated middleware for client IP resolution with configurable proxy trust

All fixes include comprehensive test coverage. No database migrations required; no existing credentials invalidated.

Test Plan

  • Added 12 tests to cron-auth.middleware.test.ts covering secret comparison and insecure defaults
  • Added 127 tests to assert-edit-user-permission.test.ts covering role assignment guards
  • Added 176 tests to client-ip.test.ts covering IP resolution with various proxy configurations
  • Added 168 tests to device.test.ts covering device creation guards
  • Added 142 tests to password.test.ts covering hash verification and salt handling
  • Added 71 tests to tanstack/i18n/request.test.ts covering redirect validation
  • Added 91 tests to websocket-origin.middleware.test.ts covering origin validation
  • Updated existing tests in upload.test.ts and admin-permission-parity.test.ts
  • All existing tests pass with the fixes applied

https://claude.ai/code/session_014QtoqPREn6SsE5M4oFnQH5

@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
vitnode-prod Ready Ready Preview Sep 6, 2026 6:09pm UTC

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +143 to +149
const parsedCursor = params.cursor ? Number(params.cursor) : undefined;
const cursorValue =
parsedCursor !== undefined &&
Number.isSafeInteger(parsedCursor) &&
parsedCursor >= 0
? Math.min(parsedCursor, MAX_SEARCH_OFFSET)
: undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +56 to +59
if (
INSECURE_CRON_SECRETS.includes(cronSecret) &&
!CONFIG.node_development
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@aXenDeveloper aXenDeveloper changed the title Security: Fix 24 vulnerabilities across auth, rate limiting, and uploads fix!: Security 24 vulnerabilities across auth, rate limiting, and uploads Sep 6, 2026
@github-actions github-actions Bot added 🐞 Bug Something isn't working 🚨 Breaking Changes Modification that will require you to update your application labels Sep 6, 2026
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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +27 to +28
description={t("errors.404.desc")}
title={t("errors.404.title")}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@aXenDeveloper aXenDeveloper changed the title fix!: Security 24 vulnerabilities across auth, rate limiting, and uploads security: Security 24 vulnerabilities across auth, rate limiting, and uploads Sep 6, 2026
@aXenDeveloper aXenDeveloper changed the title security: Security 24 vulnerabilities across auth, rate limiting, and uploads fix: Security 24 vulnerabilities across auth, rate limiting, and uploads Sep 6, 2026
@github-actions github-actions Bot added 🐞 Bug Something isn't working and removed 🐞 Bug Something isn't working 🚨 Breaking Changes Modification that will require you to update your application labels Sep 6, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@aXenDeveloper
aXenDeveloper merged commit 40b36ac into canary Sep 6, 2026
4 checks passed
@aXenDeveloper
aXenDeveloper deleted the claude/vitnode-security-review-5ly6gw branch September 6, 2026 18:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🐞 Bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants