Repository navigation
fix(security): 🔒 harden auth, rate limiting, uploads, headers, logs and dependencies - #850
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
React Doctor found no new issues. 🎉 Reviewed by React Doctor for commit |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5e997378b0
ℹ️ 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 from = | ||
| parseSearchOffset(params.cursor, MAX_SEARCH_OFFSET - size) ?? 0; |
There was a problem hiding this comment.
Make the Elasticsearch result window terminal
When a query has more than 10,000 hits, the last accessible page can return endCursor: 10000 with hasNextPage: true; sending that cursor back with a page size of 100 clamps it to 9,900, so clients repeatedly receive the same final page and infinite-query consumers can loop indefinitely. Either report no next page at the result-window boundary or reject cursors beyond the largest valid offset rather than silently clamping them backward.
Useful? React with 👍 / 👎.
| await db.transaction(async tx => { | ||
| await tx | ||
| .delete(core_users_confirm_emails) | ||
| .where(eq(core_users_confirm_emails.userId, user.id)); | ||
| await tx.insert(core_users_confirm_emails).values({ |
There was a problem hiding this comment.
Remove confirmation rows when delivery fails
If the email adapter rejects after this row is committed, the account is left with a fresh unsent token and any previously usable token has already been deleted. An immediate resend then returns 201 without sending because the unsent row triggers the five-minute cooldown, leaving the member unable to recover until the cooldown expires; preserve the previous token or remove/roll back the new row when delivery fails.
Useful? React with 👍 / 👎.
| if (respectCooldown) { | ||
| const issued = await db | ||
| .select({ createdAt: core_users_confirm_emails.createdAt }) | ||
| .from(core_users_confirm_emails) | ||
| .where(eq(core_users_confirm_emails.userId, user.id)); |
There was a problem hiding this comment.
Serialize the verification resend cooldown check
When two resend requests for the same account arrive concurrently, both can complete this read before either inserts its token, so both pass the cooldown and both send mail. Since userId is not unique in the confirmation table and the check is outside the write transaction, the advertised once-per-five-minutes protection is bypassed; lock the account/token row or enforce the cooldown atomically.
Useful? React with 👍 / 👎.
| if (starts([0x49, 0x49, 0x2a, 0x00]) || starts([0x4d, 0x4d, 0x00, 0x2a])) { | ||
| return "image/tiff"; |
There was a problem hiding this comment.
Recognize valid BigTIFF signatures
A valid BigTIFF upload begins with II 2B 00 or MM 00 2B, but this detector accepts only classic TIFF's magic value 42. Consequently, any BigTIFF file declared as image/tiff is classified as unknown and rejected by assertDeclaredImageType, even when TIFF is allowed and image processing is disabled.
Useful? React with 👍 / 👎.
| </Text> | ||
|
|
||
| {userIpAddress && ( | ||
| <Text className="text-muted-foreground mt-4 text-xs"> |
There was a problem hiding this comment.
Raise the security-note body text to 14px
When the sender IP is present, this body copy renders with Tailwind's text-xs size (12px), below the repository's explicit 14px minimum for body text; use text-sm or larger so the security information remains accessible.
AGENTS.md reference: AGENTS.md:L71-L71
Useful? React with 👍 / 👎.
… trusted proxies The Single App mounted the API without handing over the accepted socket, so every browser call resolved to 127.0.0.1 and shared one rate-limit bucket. The API also ignored VITNODE_TRUSTED_PROXY_HOPS, so behind a reverse proxy every visitor shared the proxy's bucket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB
…tial routes - secureHeaders on every API response (nosniff, frame options, COOP, ...), without HSTS and CORP, which belong to the deployment. - sign-in, sign-up, password reset and passkey sign-in get their own 10/min budget (rateLimiter.auth) on top of the global one. - IPv6 visitors are counted per /64 instead of per address. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB
…data dir `.env.production`, `.env.development` and `.env.staging` were not ignored although Vite's loadEnv reads `.env.[mode]`. Ignore `.env*` and keep `.env.example`. The single-app and API-only templates now ignore the `./docker/dev` Postgres data dir like the monorepo template does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB
…or logs `describeError()` feeds `app.onError` into `core_logs` and the console, and drizzle-orm's `DrizzleQueryError` message carries every bound parameter (addresses, password hashes, generated server secrets). Keep the SQL and the cause, redact the params, the values Postgres echoes in `detail`, and credential-looking strings. The redaction patterns are shared with `sanitizeDeliveryError` through `api/lib/redact.ts`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB
`rawApiFetch` put the full URL - the internal `VITNODE_API_URL` origin and the query, which on the SSO callback carries the OAuth `code` and `state` - into the thrown 500 and the >= 400 console line. That message can reach the SSR hydration payload. Use the pathname only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB
…s and plain http `dispatchContentRevalidation` sent `CRON_SECRET` as the bearer to every `content.revalidateOrigins` origin, including the published default and over plain http. Outside development it now refuses to dispatch while the secret is rejected (warning once), and refuses non-https origins other than localhost/127.0.0.1. The wire format is unchanged. The docs now tell receivers to compare the bearer in constant time and reject a stale `x-vitnode-timestamp`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB
The dev API config read `NOD_EMAILER_USER`, so SMTP auth never got its user while every other variable and the docs use `NODE_MAILER_*`. Also drop the `console.log` of submitted values from the admin test view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB
… advisories `pnpm-workspace.yaml` overrides, each scoped to the vulnerable range: - next 16.3.3 -> 16.3.8 (GHSA-vcvr-r3jv-pc5j), pinned by @react-email/ui - seroval/seroval-plugins 1.5.6 -> 1.6.8 (GHSA-p6vx-979v-rg4c, GHSA-jp82-f5mq-hwhp). solid-js 1.9.15 asks for `~1.5.4`; 1.9.16 asks for `~1.6.8` and calls seroval the same way, but is still inside `minimumReleaseAge`, so seroval is overridden directly for now - esbuild 0.27.7 -> 0.28.2 (GHSA-g7r4-m6w7-qqqr) - http-cache-semantics 4.2.0 -> 4.3.0 (GHSA-ch52-4w7c-c8xp, dev only) braces (GHSA-vfj7-8cjw-p6xm) stays: 3.0.3 is the newest release on npm. Add Dependabot for npm and GitHub Actions, weekly, minor/patch grouped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB
Email verification (was never sent nor enforced):
- Sign-up on an install with an email adapter now emails a 24-hour,
single-use confirmation link (32 random bytes, base64url; only the
SHA-256 digest is stored in core_users_confirm_emails).
- POST /users/verify-email spends the link atomically, marks the account
verified, emits user.email.verified and does not start a session.
- POST /users/verify-email/resend (captcha-gated, 5-minute cooldown)
answers the same 201 for every address; 404 for everyone without an
email adapter.
- Password and public passkey sign-in answer 403 email_not_verified for
an unconfirmed account, only after the credential is proven and only
when an email adapter is configured. AdminCP sign-in is not gated.
- Completing a password reset and the AdminCP "Verify Email" action also
confirm the address; the clean cron drops expired links.
- New /login/verify-email page (confirm button + resend form), sign-in
form message with a resend link, react-email template, en/pl strings,
docs and the built-in events catalogue.
Session tokens no longer appear in JSON bodies: sign-in (public and
AdminCP), the SSO callback and SSO link answer { id } only.
Password reset no longer reveals account existence on installs without
an email adapter: it answers 404 for every address before the lookup,
instead of 201 for unknown and 500 for known addresses.
Credential strings are bounded: email/name max 255, passwords max 1024
on sign-up, sign-in, reset, change-password and AdminCP create/password.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB
…limit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB
…ileged staff Editing or deleting a staff entry now requires the caller to hold every permission the entry grants. Unrestricted entries can only be changed or removed by a root/unrestricted caller. Applies to both admin and moderator entries, measured against the caller's permissions of the same staff type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB
A content write could point a file field at any `core_files` id, letting a staff member with edit access read other users' or plugins' file metadata and URLs and pin those files. A referenced file is now accepted only if it was uploaded for this content type, uploaded by the calling user, or is already held by the record (or, for a restore, its revision). Anything else is refused as CONTENT_FILE_NOT_FOUND. Calls without a user stay trusted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB
…search index `POST /admin/debug/search/rebuild` and `/search/clear` change state but only required `system:can_view`. They now require the new `system:can_manage_search` permission. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB
`buildRoute` now records the staff-permission guards it installs, and `findAdminRoutesWithoutStaffPermission` walks a mounted Hono app for routes under `/admin/` without one. Core checks its routes against an explicit, commented allowlist of handler-checked routes; the blog and example plugins must have none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB
…e_search Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB
- Pass `limitInputPixels` to every sharp call (default 40 MP, configurable via `storage.image.maxPixels`) and refuse over-limit images with a 400 before any pixel is decoded, closing the PNG image-bomb DoS. - Check the leading bytes of JPEG/PNG/WebP/GIF/AVIF/TIFF uploads against the declared Content-Type and refuse mismatches with a 400, so a client can no longer store arbitrary bytes under an image type. SVG and non-image types are untouched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB
… snippets Both serveStatic examples for the Local adapter now put storageStaticHeadersMiddleware() in front of the mount, matching apps/api, so same-origin SVG/HTML uploads cannot run script on the site. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB
…documents
Rendered pages now carry `Content-Security-Policy: frame-ancestors 'self'`,
`X-Frame-Options: SAMEORIGIN`, `X-Content-Type-Options: nosniff` and
`Referrer-Policy: strict-origin-when-cross-origin`. A header a route set
itself wins, and `createVitNodeStart({ securityHeaders })` adds, replaces or
drops (`false`) headers install-wide. No script CSP and no HSTS by default.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB
`Number(params.cursor)` went straight into `from`, so `?cursor=abc` (NaN) or an offset past `max_result_window` came back as a 500 and an error log row. The Postgres adapter's safe-integer check and MAX_SEARCH_OFFSET clamp now live in `api/lib/search-offset` and both adapters use it; Elasticsearch clamps to `MAX_SEARCH_OFFSET - size` so `from + size` stays in the window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB
`withPagination`'s `search` and the AdminCP roles list passed the raw term into `ilike`, so `%` or `_` matched every row and turned a search box into a full-table pattern scan. `escapeLikePattern` moves from the Content Engine to `lib/api/like-pattern` (still re-exported from `@vitnode/core/content/server`) and every user-supplied `ilike` uses it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB
Both built-in widgets put a free-text field straight into `href`, so a `javascript:` URL saved in a page layout ran on click. They now apply the menu-item rule (`isValidNavigationHref`: a site path or an https URL) and leave the link out otherwise. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB
… adapter @vitnode/core is only a dev dependency of the adapter, so a value import from it is not guaranteed to resolve in an install. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB
…AI routes - Switch the verify-email route's throw directive to oxlint and run oxfmt on the two files it reformats. - Stub resendEmailVerification and verifyEmail in canary's main-shell transport test. - Read expiresAt without unsafe optional chaining in the email verification test. - Allowlist canary's AI admin routes in the staff-permission audit: assist, stream and estimate are checked by the AI runner (action grant plus authorize()), available and run feedback only touch the admin's own data, and translation sources check can_edit on the named content type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5e99737 to
a35a632
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a35a632220
ℹ️ 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".
| "/@vitnode/core/users/verify-email/resend", | ||
| "/@vitnode/core/users/change-password", | ||
| "/@vitnode/core/users/passkeys/sign-in", | ||
| "/@vitnode/core/users/passkeys/admin-sign-in", | ||
| ] as const; |
There was a problem hiding this comment.
Include SSO linking in the credential rate limit
The new authentication budget omits POST /@vitnode/core/users/sso/{providerId}/link, even though that route calls PasswordModel.verifyPassword and its signed link token remains reusable for ten minutes after a wrong password. Consequently, a caller holding a link offer can make up to the much larger global allowance of expensive password guesses instead of the documented 10-per-minute credential allowance; match this dynamic route as well.
Useful? React with 👍 / 👎.
| // No account, or nothing left to confirm: the same 201 as a real send, so | ||
| // this endpoint cannot be used to find out which addresses are registered. | ||
| if (!user || user.emailVerified) { | ||
| return RESPONSE_TEXT; | ||
| } |
There was a problem hiding this comment.
Equalize resend response timing before claiming non-enumeration
For an unknown or already verified address this new endpoint returns immediately, while an unverified account awaits token writes and the email adapter before returning the same 201. An unauthenticated caller can therefore distinguish pending accounts by response latency despite the stated non-enumeration behavior; queue the delivery or perform equivalent work before responding so these branches are not observably different.
Useful? React with 👍 / 👎.
…nkEmail` component - Removed redundant code from `ResetPasswordEmailTemplate` and `VerifyEmailTemplate`. - Created a new `ActionLinkEmail` component to handle common email functionalities. - Updated email templates to utilize the new component for better maintainability. - Removed unnecessary comments and improved code clarity across various routes and services.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1f8836dec5
ℹ️ 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 [spent] = await db | ||
| .delete(core_users_confirm_emails) | ||
| .where( | ||
| and( | ||
| eq(core_users_confirm_emails.token, this.hashToken(token)), | ||
| gt(core_users_confirm_emails.expiresAt, new Date()), | ||
| ), | ||
| ) | ||
| .returning({ userId: core_users_confirm_emails.userId }); |
There was a problem hiding this comment.
Make token consumption and verification atomic
If the user update fails after this delete commits—for example during a transient database failure—the endpoint returns an error but the valid confirmation token has already been irreversibly consumed, so retrying the same link reports it as invalid while the account remains unverified. Run the token deletion and user update in one transaction so either both changes commit or neither does.
Useful? React with 👍 / 👎.
| parsedCursor >= 0 | ||
| ? Math.min(parsedCursor, MAX_SEARCH_OFFSET) | ||
| : undefined; | ||
| const cursorValue = parseSearchOffset(params.cursor); |
There was a problem hiding this comment.
Stop Postgres relevance pagination at the offset cap
The Postgres adapter independently has the boundary issue already identified for Elasticsearch: for a relevance query with more than 10,020 matches, the page at offset 10,000 reports hasNextPage: true and endCursor: 10020, but passing that cursor back is clamped to 10,000 and returns the same page indefinitely. Reject cursors beyond the window or make the page at the cap terminal.
Useful? React with 👍 / 👎.
Improving Documentation
pnpm lintpassesDescription
What?
Fixes from a security audit against an 18-point checklist. Nothing critical turned up; the most serious issue was the rate limiter putting every visitor into one bucket.
Rate limiting / client IP (items 15, 16)
127.0.0.1and shared one 80/min bucket. One client sending ~2 req/s got everyone 429s./api/$now passesgetRequestIP()(socket only, no XFF) to the API (env.clientAddress).VITNODE_TRUSTED_PROXY_HOPS, which before only the web side read.rateLimiter.authbudget, default 10/min.Auth (items 4, 5, 6)
// TODO, and an unconfirmed account could sign in straight away.POST /users/verify-emailandPOST /users/verify-email/resend(captcha-gated, same answer whether or not the address exists)./login/verify-emailpage; it confirms on a button click, not on page load, because mail scanners open every link.403 email_not_verifiedonly after the password is proven, and only when an email adapter is configured. AdminCP sign-in is not gated.user.email.verifiedevent.core_users_confirm_emailstable, so no migration.Authorization (items 1, 2, 3)
system:can_manage_searchpermission, and the AdminCP buttons are hidden without it./admin/route lacksadminStaffPermission(core, blog, example).Uploads / XSS / input (items 10–13)
limitInputPixels(40 MP default,storage.image.maxPixels). Before, a 16383×16383 PNG under 2 MB could make the server allocate about 1 GB.storageStaticHeadersMiddleware.frame-ancestors 'self',X-Frame-Options,nosniffandReferrer-Policy, overridable viacreateVitNodeStart({ securityHeaders }).secureHeaders, without HSTS or CORP.ilikeescapes%and_.Secrets / logs / debug (items 7, 8, 9, 17)
.env*(except.env.example) is ignored in the repo and the create-vitnode-app templates. The templates also ignore the Docker Postgres data dir.core_logsand the console.code/stateand the internal API origin.CRON_SECRETwhen it is unset or the default, or to non-https origins (localhost excepted).NOD_EMAILER_USER→NODE_MAILER_USER.Dependencies (item 18)
.github/dependabot.yml.Already fine, unchanged: scrypt with timing-safe compare, httpOnly cookies, CSRF, cron secret checks, parameterized SQL, sanitized rich text, no secrets in the client bundle, production error handler, Swagger off in production.
Why?
Items from the security checklist where the current design left a real gap. Each fix has tests.
Behaviour changes for reviewers
content.revalidateOriginsover plainhttp://(non-localhost) in production now stops getting notified.system:can_viewneedcan_manage_search.http:,mailto:or#anchorno longer render.shadcnCLI.Verified locally
pnpm build:scripts,build:pluginsandbuildpass.pnpm lintpasses: 0 errors; 1 warning in the generatedcontent-modules.gen.ts.tscpasses forapps/web,apps/apiand core.test+test:typespass across all packages. With Postgres: 547 core test files, 8,866 tests, none skipped.🤖 Generated with Claude Code
https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB
Generated by Claude Code