Skip to content

fix(security): 🔒 harden auth, rate limiting, uploads, headers, logs and dependencies - #850

Merged
aXenDeveloper merged 25 commits into
canaryfrom
claude/security-hardening
Oct 9, 2026
Merged

aXenDeveloper merged 25 commits into
canaryfrom
claude/security-hardening

Conversation

@aXenDeveloper

Copy link
Copy Markdown
Owner

Improving Documentation

  • pnpm lint passes
  • Docs updated next to each change (rate limiter, email verification, storage, content revalidation, start configuration, events, search permissions).

Description

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)

  • Single App: the API was mounted without the accepted socket, so every browser call resolved to 127.0.0.1 and shared one 80/min bucket. One client sending ~2 req/s got everyone 429s. /api/$ now passes getRequestIP() (socket only, no XFF) to the API (env.clientAddress).
  • Behind a proxy: the API now honours VITNODE_TRUSTED_PROXY_HOPS, which before only the web side read.
  • Credential routes: sign-in, sign-up, password reset, change-password, passkey sign-in and email verification get a separate rateLimiter.auth budget, default 10/min.
  • IPv6: keys are grouped per /64.
  • CORS was already pinned to the web origin; no change there.

Auth (items 4, 5, 6)

  • Email verification was never sent and never enforced: the sign-up route had // TODO, and an unconfirmed account could sign in straight away.
    • Sign-up now sends a link: 32 random bytes, stored as SHA-256, 24h, single use.
    • New POST /users/verify-email and POST /users/verify-email/resend (captcha-gated, same answer whether or not the address exists).
    • New /login/verify-email page; it confirms on a button click, not on page load, because mail scanners open every link.
    • Password and passkey sign-in return 403 email_not_verified only after the password is proven, and only when an email adapter is configured. AdminCP sign-in is not gated.
    • New user.email.verified event.
    • Reuses the existing, unused core_users_confirm_emails table, so no migration.
  • Session token is no longer returned in the sign-in or SSO JSON bodies. It stays in the httpOnly cookie, and nothing read it.
  • Password reset with no email adapter now answers 404 for every address. Before it was 201 for unknown and 500 for known addresses, which revealed who has an account.
  • Max lengths on credentials: email and name 255, passwords 1024.

Authorization (items 1, 2, 3)

  • A limited admin can no longer edit or delete a staff entry that is unrestricted or above their own permissions.
  • Content Engine file fields only accept files that were uploaded for that content type, uploaded by the caller, or already on the record.
  • Search rebuild/clear need a new system:can_manage_search permission, and the AdminCP buttons are hidden without it.
  • New test fails if any /admin/ route lacks adminStaffPermission (core, blog, example).

Uploads / XSS / input (items 10–13)

  • Image bomb: sharp gets limitInputPixels (40 MP default, storage.image.maxPixels). Before, a 16383×16383 PNG under 2 MB could make the server allocate about 1 GB.
  • Declared types checked: a raster image whose bytes don't match its declared type is rejected with a 400.
  • Docs: the local-storage snippets now mount storageStaticHeadersMiddleware.
  • Headers on HTML pages: frame-ancestors 'self', X-Frame-Options, nosniff and Referrer-Policy, overridable via createVitNodeStart({ securityHeaders }).
  • Headers on the API: Hono secureHeaders, without HSTS or CORP.
  • Smaller fixes:
    • The Elasticsearch cursor is validated and clamped.
    • ilike escapes % and _.
    • Hero and CTA widget links render only for safe hrefs.

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.
  • Drizzle query params and values Postgres echoes back are redacted before they reach core_logs and the console.
  • Fetcher errors and logs carry only the URL path. Before, they included OAuth code/state and the internal API origin.
  • Content revalidation no longer sends CRON_SECRET when it is unset or the default, or to non-https origins (localhost excepted).
  • Fixed NOD_EMAILER_USER → NODE_MAILER_USER.

Dependencies (item 18)

  • Overrides for next (GHSA-vcvr-r3jv-pc5j), seroval, esbuild and http-cache-semantics.
  • Added .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

  • On an install that already has an email adapter, existing accounts are unverified, because no email was ever sent. They must request a new link, reset their password, or be verified by staff before signing in. There is no bulk-verify migration, because that would also verify accounts registered with someone else's address.
  • content.revalidateOrigins over plain http:// (non-localhost) in production now stops getting notified.
  • Staff who rebuilt search with only system:can_view need can_manage_search.
  • Saved Hero/CTA links that are http:, mailto: or #anchor no longer render.
  • seroval 1.6.x is outside solid-js 1.9.15's declared range. The diff shows no change to how solid-js calls it; the override can be dropped once solid-js 1.9.16 resolves.
  • One high advisory remains: braces 3.0.3, which only comes in through the shadcn CLI.

Verified locally

  • pnpm build:scripts, build:plugins and build pass.
  • pnpm lint passes: 0 errors; 1 warning in the generated content-modules.gen.ts.
  • tsc passes for apps/web, apps/api and core.
  • test + test:types pass 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

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-09T10:42:10.189900Z 1f8836d New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions github-actions Bot added the 🐞 Bug Something isn't working label Oct 7, 2026
@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

React Doctor found no new issues. 🎉

Reviewed by React Doctor for commit 1f8836d.

@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: 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".

Comment on lines +421 to +422
const from =
parseSearchOffset(params.cursor, MAX_SEARCH_OFFSET - size) ?? 0;

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

Comment on lines +90 to +94
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({

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

Comment on lines +75 to +79
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));

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

Comment on lines +70 to +71
if (starts([0x49, 0x49, 0x2a, 0x00]) || starts([0x4d, 0x4d, 0x00, 0x2a])) {
return "image/tiff";

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

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

claude and others added 24 commits October 9, 2026 11:46
… 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
…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
- 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
…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>
@aXenDeveloper
aXenDeveloper force-pushed the claude/security-hardening branch from 5e99737 to a35a632 Compare October 9, 2026 09:54

@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: 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".

Comment on lines +133 to +137
"/@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;

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

Comment on lines +76 to +80
// 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;
}

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

@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: 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".

Comment on lines +111 to +119
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 });

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

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

@aXenDeveloper
aXenDeveloper added this pull request to stack #860 October 9, 2026 17:08
@aXenDeveloper
aXenDeveloper merged commit f5654c3 into canary Oct 9, 2026
8 checks passed
@aXenDeveloper
aXenDeveloper deleted the claude/security-hardening branch October 9, 2026 20:47
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