Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
78abbf2
fix(api): 🔒 resolve real client IPs for the in-process API and behind…
claude Oct 7, 2026
3f74868
fix(api): 🔒 add security headers and a stricter rate limit for creden…
claude Oct 7, 2026
f6eb742
fix(security): 🔒 ignore every .env mode file and the Docker Postgres …
claude Oct 7, 2026
388621f
fix(security): 🔒 redact bound query params and echoed values from err…
claude Oct 7, 2026
b41ad7c
fix(security): 🔒 log and throw only the API path from the fetcher
claude Oct 7, 2026
a419d86
fix(security): 🔒 keep the content revalidation bearer off weak secret…
claude Oct 7, 2026
45eff7e
fix(api): 🐛 read NODE_MAILER_USER for the nodemailer adapter
claude Oct 7, 2026
29687ae
chore(deps): ⬆️ patch next, seroval, esbuild and http-cache-semantics…
claude Oct 7, 2026
c517ebe
feat(auth): 🔒 enforce email verification and harden sign-in responses
claude Oct 7, 2026
08adf02
fix(api): 🔒 put the email verification routes on the credential rate …
claude Oct 7, 2026
31e162b
fix(admin): 🔒 stop limited admins from demoting or removing more priv…
claude Oct 7, 2026
c166d3c
fix(content): 🔒 only accept file references the caller may use
claude Oct 7, 2026
d00b1be
fix(admin): 🔒 require a dedicated permission to rebuild or clear the …
claude Oct 7, 2026
8446f81
test(admin): 🧪 assert every admin route requires a staff permission
claude Oct 7, 2026
9377f19
fix(admin): 🔒 hide search rebuild and clear actions without can_manag…
claude Oct 7, 2026
cb8bd70
fix(storage): 🔒 cap decoded image pixels and verify declared image types
claude Oct 7, 2026
18b1a25
docs(storage): 🔒 mount storageStaticHeadersMiddleware in local upload…
claude Oct 7, 2026
eba45fe
docs(storage): 📝 format image optimization config snippet
claude Oct 7, 2026
6b05d22
fix(start): 🔒 send anti-clickjacking and hardening headers with HTML …
claude Oct 7, 2026
2742d3e
fix(search): 🔒 validate and clamp the Elasticsearch result cursor
claude Oct 7, 2026
9607279
fix(api): 🔒 escape LIKE wildcards in paginated and role-name searches
claude Oct 7, 2026
ca3f844
fix(widgets): 🔒 render Hero and Call to action links only for safe hrefs
claude Oct 7, 2026
807eeba
refactor(elasticsearch): ♻️ keep the search offset clamp local to the…
claude Oct 7, 2026
a35a632
fix(rebase): 🐛 reconcile security hardening with canary's oxlint and …
aXenDeveloper Oct 9, 2026
1f8836d
refactor(emails): ✨ Simplify email templates by introducing `ActionLi…
aXenDeveloper Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
version: 2

updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: monday
cooldown:
default-days: 3
open-pull-requests-limit: 10
commit-message:
prefix: chore(deps)
groups:
npm-minor-patch:
update-types:
- minor
- patch

- package-ecosystem: github-actions
directories:
- /
- /.github/actions/setup
schedule:
interval: weekly
day: monday
commit-message:
prefix: ci(deps)
groups:
github-actions:
update-types:
- minor
- patch
7 changes: 2 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,8 @@ node_modules
.turbo

# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env*
!.env.example

# Build Outputs
out/
Expand Down
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,14 @@ npm i x
- `vi.mock` is allowed only as a last resort, for a boundary with no seam (a `React.lazy` editor, a child process). Never use it for a module the test could drive for real.
- Staff permissions: grant them with `grantStaffPermissions` from `@/tests/staff-permissions` on a `createTestCache()` from `@/tests/cache`, set as `c.set("cache", cache)`. Never mock `check-staff-permission`. Assert on the response (`200` with the permission, `403` without), not on which permission was asked for.
- `use-intl` is mocked once for every test in `packages/vitnode/src/tests/setup.ts`: `t("key")` renders `namespace.key`. Don't mock it per file.

<!-- BEGIN:turborepo-agent-rules -->

# This is NOT the Turborepo you know

Turborepo configuration, task behavior, and CLI commands can vary between installed versions and may differ from your training data. Resolve the `turbo` package from this file's directory or relevant workspace; in monorepos, it may not be visible from the repository root. For example, run `node -p "require.resolve('turbo/package.json')"` from a workspace that depends on `turbo`.

Read `docs/README.md` inside that installed package first, then read the relevant pages from its `docs/` directory before changing Turborepo configuration or commands. Heed deprecation notices. These bundled docs match the installed package version and are available without network access.

This block is written and re-added by `turbo` before repository-scoped commands when an AI agent is detected. In the Turborepo source repository, its template is defined in `crates/turborepo-cli/src/cli/agent_guidance.rs`. Removing the managed block while updates are enabled means a later qualifying invocation will add it again. Set `"agentGuidance": false` in the root `turbo.json` or `turbo.jsonc` to opt out; this does not remove an existing block. Keep the block committed with your work to avoid an uncommitted change on the next agent invocation.
<!-- END:turborepo-agent-rules -->
12 changes: 6 additions & 6 deletions apps/api/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ yarn-debug.log*
yarn-error.log*

# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env*
!.env.example

# Others
.vercel

# Local storage adapter uploads
/public/uploads
/public/uploads

# VitNode
/docker
2 changes: 1 addition & 1 deletion apps/api/src/vitnode.api.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export const vitNodeApiConfig = buildApiConfig({
from: process.env.NODE_MAILER_FROM,
host: process.env.NODE_MAILER_HOST,
password: process.env.NODE_MAILER_PASSWORD,
user: process.env.NOD_EMAILER_USER,
user: process.env.NODE_MAILER_USER,
}),
logo: {
text: "VitNode Email Test",
Expand Down
3 changes: 2 additions & 1 deletion apps/web/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ node_modules
dist
dist-ssr
*.local
.env
.env*
!.env.example
.nitro
.tanstack
.wrangler
Expand Down
3 changes: 2 additions & 1 deletion apps/web/content/docs/dev/advanced/auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,10 @@ The AdminCP is the keys to the kingdom, so its session is deliberately short-liv

## Security Guarantees

- **SHA-256 Token Storage**: The raw token is stored only in the user's `HttpOnly` cookie. The database stores only its cryptographic hash.
- **SHA-256 Token Storage**: The raw token is stored only in the user's `HttpOnly` cookie. The database stores only its cryptographic hash. Sign-in, SSO and passkey answers carry the user id only - page scripts never see the token.
- **Device Pinning**: Tokens are bound to a verified device ID. Tokens copied to another device without the matching device cookie are rejected.
- **Fast 60-Second Caching**: Active sessions are cached in Redis for up to 60 seconds, eliminating database query overhead on repeated requests.
- **Confirmed Email Addresses**: With an email adapter, members confirm their address before their first sign-in. See [Email Verification](/docs/dev/working-with-users/email-verification).
- **Canonical Email Identity**: Addresses are stored in their canonical form, so Gmail dot, `googlemail.com` and `+tag` variants cannot become a second account. See [One Mailbox, One Account](#one-mailbox-one-account).

---
Expand Down
32 changes: 31 additions & 1 deletion apps/web/content/docs/dev/advanced/rate-limiter.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,31 @@ export const vitNodeApiConfig = buildApiConfig({

The default configuration allows **80 requests per 60 seconds** per IP address.

IPv6 visitors are counted per `/64` network, not per address - an ISP usually hands one person a whole `/64`, and we'd rather not give them 18 quintillion fresh budgets.

## Stricter budget for sign-in

Routes that check a password or send an email to whoever is named in the request get a second, much smaller budget on top of the global one: **10 requests per 60 seconds** per IP address. That covers:

- `POST /users/sign_in` and `POST /users/sign_up`
- `POST /users/reset-password` and `POST /users/change-password`
- `POST /users/verify-email` and `POST /users/verify-email/resend`
- `POST /users/passkeys/sign-in` and `POST /users/passkeys/admin-sign-in`

Tune it with `rateLimiter.auth`:

```ts title="apps/api/src/vitnode.api.config.ts"
export const vitNodeApiConfig = buildApiConfig({
rateLimiter: {
// [!code ++:4]
auth: {
points: 5,
duration: 60,
},
},
})
```

<Callout type="info" title="Disabled in development">
Rate limiting is automatically bypassed when `NODE_ENV=development` to prevent interruptions during local coding.
</Callout>
Expand Down Expand Up @@ -73,7 +98,7 @@ When your web app renders a page on the server, it calls the API itself - so the
The API never believes a plain `X-Forwarded-For` header (anyone can type one). Instead, the web server works out the visitor's address, signs it with a key derived from your `CRON_SECRET` - the secret the web app and the API already share - and the API checks that signature before trusting it.

- Set the same `CRON_SECRET` in both apps (you need it there anyway).
- Tell the web app how many proxies sit in front of it with `VITNODE_TRUSTED_PROXY_HOPS`. It skips exactly that many hops from the right of `X-Forwarded-For` and ignores everything further left, because a visitor can write whatever they like there.
- Tell each app how many proxies sit in front of it with `VITNODE_TRUSTED_PROXY_HOPS`. It skips exactly that many hops from the right of `X-Forwarded-For` and ignores everything further left, because a visitor can write whatever they like there. The API reads it too, for browser calls that reach it through your proxy - in a split deployment, set it to the proxies in front of the **API**, and leave it at `0` if the API is only reachable from the web server.
- The API trusts the forwarded address only when the signature matches and is less than 5 minutes old.
- While `CRON_SECRET` is unset or still a published placeholder, nothing is trusted: the API keys every request on the connection address.

Expand Down Expand Up @@ -107,6 +132,11 @@ The API never believes a plain `X-Forwarded-For` header (anyone can type one). I
description: "Window length in seconds before points reset.",
type: "number",
},
auth: {
default: "{ points: 10, duration: 60 }",
description: "Separate budget for sign-in, sign-up, password reset and passkey sign-in.",
type: "{ points?: number; duration?: number }",
},
}}
/>

Expand Down
30 changes: 15 additions & 15 deletions apps/web/content/docs/dev/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,15 @@ VitNode separates concerns between two core layers:

When a user visits a page (e.g. `/blog`):

| Phase | Runtime | Action |
| :------------------------ | :--------------------- | :------------------------------------------------------------------ |
| **1. Request** | Browser | Visitor navigates to `/blog` |
| **2. Routing** | Server (SSR) / Browser | TanStack Router matches route and executes `loader` |
| **3. Query Warming** | Server / Browser | `context.queryClient.query` executes isomorphic fetcher |
| **4. RPC Call** | Server / Browser | The universal `fetcher` calls the Hono endpoint by plugin id |
| **5. API Middleware** | Server (Hono) | Verifies session cookie, applies rate limits, injects `c.get(db)` |
| **6. Handler & Database** | Server (Hono) | Handler validates input and queries PostgreSQL via Drizzle |
| **7. Response** | Server / Browser | JSON data hydrates TanStack Query cache and paints component |
| Phase | Runtime | Action |
| :------------------------ | :--------------------- | :---------------------------------------------------------------- |
| **1. Request** | Browser | Visitor navigates to `/blog` |
| **2. Routing** | Server (SSR) / Browser | TanStack Router matches route and executes `loader` |
| **3. Query Warming** | Server / Browser | `context.queryClient.query` executes isomorphic fetcher |
| **4. RPC Call** | Server / Browser | The universal `fetcher` calls the Hono endpoint by plugin id |
| **5. API Middleware** | Server (Hono) | Verifies session cookie, applies rate limits, injects `c.get(db)` |
| **6. Handler & Database** | Server (Hono) | Handler validates input and queries PostgreSQL via Drizzle |
| **7. Response** | Server / Browser | JSON data hydrates TanStack Query cache and paints component |

---

Expand All @@ -50,12 +50,12 @@ Before route matching, every request passes through the middleware
`createVitNodeStart` installs - in this order, and an app cannot get in front of
any of it:

| Order | Middleware | Applies to |
| :---- | :----------------- | :------------------------------------------------------------- |
| 1 | **CSRF** | Server function calls (`handlerType === 'serverFn'`) |
| 2 | **Locale** | Page requests: canonical `308` redirects and the locale cookie |
| 3 | **Document cache** | HTML responses: forced `Cache-Control: private, no-store` |
| 4 | Your own | Whatever `requestMiddleware` lists |
| Order | Middleware | Applies to |
| :---- | :----------------- | :------------------------------------------------------------------------------ |
| 1 | **CSRF** | Server function calls (`handlerType === 'serverFn'`) |
| 2 | **Locale** | Page requests: canonical `308` redirects and the locale cookie |
| 3 | **Document cache** | HTML responses: forced `Cache-Control: private, no-store` plus security headers |
| 4 | Your own | Whatever `requestMiddleware` lists |

`/api/*` reaches the same middleware and passes through untouched - no redirect,
no rewrite, no cache directive - so the Hono bridge sees the request exactly as
Expand Down
38 changes: 33 additions & 5 deletions apps/web/content/docs/dev/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -209,11 +209,11 @@ export const startInstance = createVitNodeStart({ config: vitNodeConfig })

`createVitNodeStart` installs three things, and none of them is optional:

| Middleware | What it does |
| :------------ | :---------------------------------------------------------------------------------------- |
| **CSRF** | Rejects cross-site requests to server functions (`handlerType === 'serverFn'`) |
| **Locale** | Canonical `308` redirects, the remembered-locale cookie, and the document cache directive |
| **Documents** | Forces `Cache-Control: private, no-store` onto every HTML response |
| Middleware | What it does |
| :------------ | :----------------------------------------------------------------------------------------- |
| **CSRF** | Rejects cross-site requests to server functions (`handlerType === 'serverFn'`) |
| **Locale** | Canonical `308` redirects, the remembered-locale cookie, and the document cache directive |
| **Documents** | Forces `Cache-Control: private, no-store` and adds security headers to every HTML response |

<Callout type="warn" title="Why the factory exists">
Start installs its own CSRF middleware *only* while an app declares no
Expand Down Expand Up @@ -243,6 +243,34 @@ Only `text/html` responses are touched. Everything else keeps what it had.
directive like any other page an operator reads.
</Callout>

### Security headers on every page

Every HTML response also gets a small, conservative set of security headers:

| Header | Value | Why |
| :------------------------ | :-------------------------------- | :-------------------------------------------------------- |
| `Content-Security-Policy` | `frame-ancestors 'self'` | Other sites cannot frame your pages (hello, clickjacking) |
| `X-Frame-Options` | `SAMEORIGIN` | The same, for browsers that predate `frame-ancestors` |
| `X-Content-Type-Options` | `nosniff` | The browser trusts the declared content type |
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Paths and query strings stay out of other sites' logs |

There is no script policy and no HSTS by default - a strict script CSP breaks
plugins in creative ways, and HSTS is a promise you should only make once your
domain is HTTPS for good. A header a route sets for itself always wins. To add,
change or drop one install-wide, pass `securityHeaders` (`false` removes a
default):

```ts title="apps/web/src/start.ts"
export const startInstance = createVitNodeStart({
config: vitNodeConfig,
// [!code ++:4]
securityHeaders: {
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
'Referrer-Policy': 'no-referrer',
},
})
```

### Adding your own middleware

For things your installation genuinely owns - a request id, a tracing span, a
Expand Down
2 changes: 2 additions & 0 deletions apps/web/content/docs/dev/content-engine/fields.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ gallery: field.file({ multiple: true, min: 1, max: 8, maxBytes: 5 * 1024 * 1024

Files are uploaded through the [storage](/docs/dev/storage) adapter and recorded in `core_files`. The column uses `ON DELETE RESTRICT`, so a file in use cannot be deleted. In the public API a file is returned as `{ id, name, url, mimeType, size, width, height }`, never as a raw id.

A write can only point a file field at a file it is allowed to use: one uploaded through this content type's own upload route, one the signed-in user uploaded themselves, or one the record already holds. Anything else - say, someone else's avatar found by guessing ids - is refused with `CONTENT_FILE_NOT_FOUND`, the same answer as an id that does not exist. Server code running without a user (a job, a seed) is trusted to pick its own ids.

## Relation

`field.relation({ target: () => categoryContentType, required: true })` links to another content type. Set `required` or `nullable`: a single relation without either throws. All options are in [Relations and advanced fields](/docs/dev/content-engine/relations-and-advanced-modeling#relation-options).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,3 +245,38 @@ export const vitNodeApiConfig = buildApiConfig({
```

Leave it unset when your pages read the public API on every request.

Because the request carries your `CRON_SECRET` - the same key that runs every cron job - VitNode is picky about where it sends it:

- Each origin must use `https`. Plain `http` is only allowed for `localhost` and `127.0.0.1`.
- Outside development, nothing is sent while `CRON_SECRET` is unset or still the built-in default. The API logs a warning once and the scheduled run records the origins as not delivered.

### Build the revalidate endpoint

Your endpoint is the lock on that door, so check two things before you expire anything:

1. **The bearer, in constant time.** A plain `===` stops at the first wrong character, and that timing leaks the secret one character at a time.
2. **The `x-vitnode-timestamp` header.** It holds the send time in milliseconds. Refuse anything more than 5 minutes away from your clock, so a captured request can't be replayed next week.

```ts title="apps/web/src/routes/api/vitnode/content/revalidate.ts"
import { createHash, timingSafeEqual } from "node:crypto";

const MAX_SKEW_MS = 5 * 60 * 1000;

// Hashing first gives both sides the same length, which `timingSafeEqual` needs.
const digest = (value: string) => createHash("sha256").update(value).digest();

export const isTrustedRevalidation = (request: Request): boolean => {
const secret = process.env.CRON_SECRET;
if (!secret) return false;

const bearer = /^Bearer (.+)$/.exec(request.headers.get("authorization") ?? "")?.[1];
if (!bearer || !timingSafeEqual(digest(bearer), digest(secret))) return false;

const sentAt = Number(request.headers.get("x-vitnode-timestamp"));

return Number.isFinite(sentAt) && Math.abs(Date.now() - sentAt) <= MAX_SKEW_MS;
};
```

Answer `403` when it returns `false` - VitNode reads that as a wrong secret and skips the retry - and a `2xx` once the tags are expired. The JSON body is the invalidation input: the content type, the record `id`, its `slugs` and whether it `wasPublic` and `isPublic`. Pass it to `contentInvalidationTags` from `@vitnode/core/content` to get the exact tags to expire.
Loading
Loading