Skip to content

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

Open
aXenDeveloper wants to merge 5 commits into
canaryfrom
claude/vitnode-security-review-5ly6gw
Open

Security: Fix 24 vulnerabilities across auth, rate limiting, and uploads#783
aXenDeveloper wants to merge 5 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

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
@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 2, 2026 9:58am UTC

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

Hey there and thank you for opening this pull request! 👋🏼

We require pull request titles to follow the Conventional Commits specification and it looks like your proposed title needs to be adjusted.

Details:

Unknown release type "Security" found in pull request title "Security: Fix 24 vulnerabilities across auth, rate limiting, and uploads".

Available types:
 - feat: A new feature
 - fix: A bug fix
 - docs: Documentation only changes
 - style: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
 - refactor: A code change that neither fixes a bug nor adds a feature
 - perf: A code change that improves performance
 - test: Adding missing tests or correcting existing tests
 - build: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
 - ci: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)
 - chore: Other changes that don't modify src or test files
 - revert: Reverts a previous commit

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants