diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000000..f02fedb43b
--- /dev/null
+++ b/.github/dependabot.yml
@@ -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
diff --git a/.gitignore b/.gitignore
index 200767ba9a..49bffe2447 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
diff --git a/AGENTS.md b/AGENTS.md
index fc796d0a66..73323238a9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
+
+
+
+# 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.
+
diff --git a/apps/api/.gitignore b/apps/api/.gitignore
index a6055938d1..0b346262cc 100644
--- a/apps/api/.gitignore
+++ b/apps/api/.gitignore
@@ -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
\ No newline at end of file
+/public/uploads
+
+# VitNode
+/docker
diff --git a/apps/api/src/vitnode.api.config.ts b/apps/api/src/vitnode.api.config.ts
index efeb874750..1304f9d0ae 100644
--- a/apps/api/src/vitnode.api.config.ts
+++ b/apps/api/src/vitnode.api.config.ts
@@ -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",
diff --git a/apps/web/.gitignore b/apps/web/.gitignore
index 8b25bb54ef..97ddc898a5 100644
--- a/apps/web/.gitignore
+++ b/apps/web/.gitignore
@@ -3,7 +3,8 @@ node_modules
dist
dist-ssr
*.local
-.env
+.env*
+!.env.example
.nitro
.tanstack
.wrangler
diff --git a/apps/web/content/docs/dev/advanced/auth.mdx b/apps/web/content/docs/dev/advanced/auth.mdx
index 4c4ff15807..02b712a475 100644
--- a/apps/web/content/docs/dev/advanced/auth.mdx
+++ b/apps/web/content/docs/dev/advanced/auth.mdx
@@ -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).
---
diff --git a/apps/web/content/docs/dev/advanced/rate-limiter.mdx b/apps/web/content/docs/dev/advanced/rate-limiter.mdx
index edf00fb6c9..727b522c9e 100644
--- a/apps/web/content/docs/dev/advanced/rate-limiter.mdx
+++ b/apps/web/content/docs/dev/advanced/rate-limiter.mdx
@@ -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,
+ },
+ },
+})
+```
+
Rate limiting is automatically bypassed when `NODE_ENV=development` to prevent interruptions during local coding.
@@ -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.
@@ -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 }",
+ },
}}
/>
diff --git a/apps/web/content/docs/dev/architecture.mdx b/apps/web/content/docs/dev/architecture.mdx
index fd8e50d291..df99a0d4ca 100644
--- a/apps/web/content/docs/dev/architecture.mdx
+++ b/apps/web/content/docs/dev/architecture.mdx
@@ -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 |
---
@@ -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
diff --git a/apps/web/content/docs/dev/configuration.mdx b/apps/web/content/docs/dev/configuration.mdx
index 52a3047ca3..cf620736db 100644
--- a/apps/web/content/docs/dev/configuration.mdx
+++ b/apps/web/content/docs/dev/configuration.mdx
@@ -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 |
Start installs its own CSRF middleware *only* while an app declares no
@@ -243,6 +243,34 @@ Only `text/html` responses are touched. Everything else keeps what it had.
directive like any other page an operator reads.
+### 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
diff --git a/apps/web/content/docs/dev/content-engine/fields.mdx b/apps/web/content/docs/dev/content-engine/fields.mdx
index 37ef3ee869..5bee8c2ab4 100644
--- a/apps/web/content/docs/dev/content-engine/fields.mdx
+++ b/apps/web/content/docs/dev/content-engine/fields.mdx
@@ -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).
diff --git a/apps/web/content/docs/dev/content-engine/public-api-and-caching.mdx b/apps/web/content/docs/dev/content-engine/public-api-and-caching.mdx
index 5452a4e389..11990a7a3e 100644
--- a/apps/web/content/docs/dev/content-engine/public-api-and-caching.mdx
+++ b/apps/web/content/docs/dev/content-engine/public-api-and-caching.mdx
@@ -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.
diff --git a/apps/web/content/docs/dev/events/built-in-events.mdx b/apps/web/content/docs/dev/events/built-in-events.mdx
index a8f984a2e6..4f1f64b72a 100644
--- a/apps/web/content/docs/dev/events/built-in-events.mdx
+++ b/apps/web/content/docs/dev/events/built-in-events.mdx
@@ -47,6 +47,7 @@ the write it describes has committed.
| `user.created` | `{ userId, email, name, emailVerified }` | A user row is inserted - public sign-up, AdminCP creation, or SSO sign-up |
| `user.updated` | `{ userId, email, name }` | A user is edited in the AdminCP, or a member saves their own profile or time zone in **Settings → Overview** |
| `user.deleted` | `{ userId, email }` | Never - the name is declared for plugins, core has no deletion flow |
+| `user.email.verified` | `{ userId, email }` | An address is [confirmed](/docs/dev/working-with-users/email-verification) - by its link, a completed reset, or by staff |
| `user.password.updated` | `{ userId }` | A password is set - by a member completing a reset, or by staff on the AdminCP user page |
| `user.sessions.revoked` | `{ userId, deviceId, sessions }` | A user is signed out remotely - one device or all of them, by the member or by staff, or after a password change |
| `user.sso.linked` | `{ userId, email, providerId }` | An SSO identity is linked to an existing account - at login with its password, or from **Settings → Connected accounts** |
@@ -270,6 +271,31 @@ avatar that failed to download never counts. A name change also emits
**A listener would** refresh a search index or a cached member card.
+
+
+
+Emitted once an unconfirmed address becomes confirmed: when a member clicks
+the link from their sign-up email, when they complete a password reset (the
+reset link went to the same mailbox), or when staff press **Verify Email** on
+the AdminCP user page. A reset on an account that was already confirmed emits
+nothing here. See
+[Email Verification](/docs/dev/working-with-users/email-verification).
+
+
+
+**A listener would** send a welcome email or unlock a "verified member" badge.
+
diff --git a/apps/web/content/docs/dev/search-elasticsearch.mdx b/apps/web/content/docs/dev/search-elasticsearch.mdx
index 774b10a1b6..d4fd58d065 100644
--- a/apps/web/content/docs/dev/search-elasticsearch.mdx
+++ b/apps/web/content/docs/dev/search-elasticsearch.mdx
@@ -70,7 +70,9 @@ also accepts `username` and `password` for a self-hosted secured cluster.
Restart the API, then open **Core → Advanced → Search** in the AdminCP and run
**Rebuild index**. New changes are mirrored automatically; the rebuild fills
-the historical records already in Postgres.
+the historical records already in Postgres. Rebuilding (and clearing a
+collection) needs the **Rebuild and clear the search index** permission
+(`system:can_manage_search`) - viewing the page alone is not enough.
{/* Image prompt: VitNode AdminCP search page showing Elasticsearch as connected, one “Rebuild index” button, a progress state, and collection counts. Dark theme, 1440x900. */}
diff --git a/apps/web/content/docs/dev/storage/index.mdx b/apps/web/content/docs/dev/storage/index.mdx
index fdf28e429b..8f092c35c2 100644
--- a/apps/web/content/docs/dev/storage/index.mdx
+++ b/apps/web/content/docs/dev/storage/index.mdx
@@ -99,17 +99,28 @@ export const uploadCoverRoute = buildRoute({
## Image Optimization
-The storage service can automatically convert uploaded images to WebP and constrain dimensions:
+Turn on `storage.image` and VitNode re-encodes JPEG, PNG, AVIF, TIFF and WebP uploads with [sharp](https://sharp.pixelplumbing.com/), converts them to WebP, and records their pixel dimensions:
-```ts
-const uploaded = await c.get("storage").upload({
- file,
- folder: "avatars",
- convertImagesToWebp: true, // [!code ++]
- maxDimensions: { width: 1200, height: 1200 }, // [!code ++]
+```ts title="apps/api/src/vitnode.api.config.ts"
+export const vitNodeApiConfig = buildApiConfig({
+ storage: {
+ adapter: LocalStorageAdapter(),
+ // [!code ++:5]
+ image: {
+ quality: 85, // 1-100, defaults to 85
+ webp: true, // set to false to keep the original format
+ maxPixels: 40_000_000, // width x height, defaults to 40 million
+ },
+ },
})
```
+`maxPixels` is your guard against image bombs - a tiny PNG that claims to be 16383x16383 pixels and unpacks into gigabytes of memory. Anything over the limit is refused with a `400` before a single pixel is decoded. The default fits an 8K photo with room to spare, so most installs never need to touch it.
+
+### What gets checked on every upload
+
+Whether or not `storage.image` is set, an upload declared as JPEG, PNG, WebP, GIF, AVIF or TIFF must actually _be_ that format. The type a client sends is just a claim (a browser guesses it from the file name), so VitNode reads the file's first bytes and refuses a mismatch - say, an HTML page named `cat.gif` - with a `400`. SVG and non-image files have no fixed signature and are not affected; limit them with `allowedMimeTypes`.
+
---
## Deleting Files
diff --git a/apps/web/content/docs/dev/storage/local.mdx b/apps/web/content/docs/dev/storage/local.mdx
index a93115dd00..26d0d93072 100644
--- a/apps/web/content/docs/dev/storage/local.mdx
+++ b/apps/web/content/docs/dev/storage/local.mdx
@@ -73,14 +73,16 @@ derived from your `publicPath` instead of hardcoded in two places.
```ts tab="Standalone API" title="apps/api/src/index.ts"
import { serveStatic } from '@hono/node-server/serve-static'
+import { storageStaticHeadersMiddleware } from '@vitnode/core/api/middlewares/storage-static.middleware'
import { mkdirSync } from 'node:fs'
-// [!code ++:14]
+// [!code ++:15]
const staticStorage = vitNodeApiConfig.storage?.adapter?.static
if (staticStorage) {
mkdirSync(staticStorage.root, { recursive: true })
app.get(
staticStorage.mountPath,
+ storageStaticHeadersMiddleware(),
serveStatic({
root: staticStorage.root,
rewriteRequestPath: (path) =>
@@ -96,17 +98,19 @@ VitNodeAPI({ app, vitNodeApiConfig })
```ts tab="TanStack Start app" title="src/server/vitnode-api.server.ts"
import { serveStatic } from '@hono/node-server/serve-static'
+import { storageStaticHeadersMiddleware } from '@vitnode/core/api/middlewares/storage-static.middleware'
import { mkdirSync } from 'node:fs'
const createVitNodeApi = () => {
const app = new OpenAPIHono().basePath('/api')
- // [!code ++:14]
+ // [!code ++:15]
const staticStorage = vitNodeApiConfig.storage?.adapter?.static
if (staticStorage) {
mkdirSync(staticStorage.root, { recursive: true })
app.get(
staticStorage.mountPath,
+ storageStaticHeadersMiddleware(),
serveStatic({
root: staticStorage.root,
rewriteRequestPath: (path) =>
@@ -130,6 +134,15 @@ then skips the CORS, CSRF, rate-limiter and global middleware, which a static
image has no use for. `mkdirSync` is only there so `serveStatic` does not warn
about a missing root before the first upload.
+
+ Local uploads are served from your own origin - the one your session cookie
+ lives on. Without this middleware, an uploaded SVG or HTML file opened
+ directly in the browser runs its script **as your site**, signed in as
+ whoever clicked the link. The middleware adds `Content-Security-Policy:
+ sandbox` and `X-Content-Type-Options: nosniff`, so stored files can still be
+ shown in an `
` but never execute. It costs nothing - leave it in.
+
+
The TanStack Start app is the same code in a different file, because `/api/*` on
that app is one catch-all route handing the request to the very same Hono
application - so the mount belongs inside it, not in the router. `serveStatic`
diff --git a/apps/web/content/docs/dev/widgets/defining-a-block.mdx b/apps/web/content/docs/dev/widgets/defining-a-block.mdx
index 8fff51c89f..068f73f1de 100644
--- a/apps/web/content/docs/dev/widgets/defining-a-block.mdx
+++ b/apps/web/content/docs/dev/widgets/defining-a-block.mdx
@@ -77,6 +77,10 @@ interface WidgetComponentProps {
`data` has already been through the widget's schema when the component runs, so a component never checks for missing fields. `variant` is the look this instance picked, and is `undefined` for a widget that offers none.
+
+ The schema checks that a `field.text()` is a string, not that it is a URL you want people clicking. If your widget turns one into an `href`, check it with `isValidNavigationHref` from `@vitnode/core/lib/navigation` first - it allows a site path (`/discover`) or an `https://` URL and refuses `javascript:` and friends. Core's Hero and Call to action widgets simply leave the link out when it fails.
+
+
## Two looks, one widget
A widget that should render the same content in more than one shape declares its shapes instead of growing a `style` field:
diff --git a/apps/web/content/docs/dev/working-with-users/email-verification.mdx b/apps/web/content/docs/dev/working-with-users/email-verification.mdx
new file mode 100644
index 0000000000..f82b5ac455
--- /dev/null
+++ b/apps/web/content/docs/dev/working-with-users/email-verification.mdx
@@ -0,0 +1,89 @@
+---
+title: Email Verification
+description: How VitNode confirms a new member's email address before they can sign in, and the API routes and events behind it.
+icon: MailCheck
+---
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+When your install has an [email adapter](/docs/dev/email), every new member has to click a confirmation link before they can sign in. Without one, there is nobody to send the link, so accounts are confirmed the moment they are created and nothing changes.
+
+That stops the oldest trick in the book: signing up with somebody else's address and using the account straight away.
+
+## What a member sees
+
+1. They register at `/register`. Instead of being signed in, they get a "Check your email" card.
+2. The email holds a link to `/login/verify-email?token=...`, valid for **24 hours**.
+3. That page asks them to click **Confirm email address**. The page never confirms on load, because mail scanners and link previews open every URL in an email.
+4. Once confirmed, they sign in with their password as usual.
+
+If they try to sign in first, the sign-in form says **Confirm your email first** and offers **Send a new confirmation link**, which opens the resend form with their address already filled in.
+
+
+ The "not confirmed" message only appears after the password has been checked.
+ A wrong password, an unknown address and an unconfirmed account with a wrong
+ password all get the same `403`, so the sign-in form can't be used to find out
+ which addresses are registered.
+
+
+## The rules
+
+- **Password sign-in** answers `403 { "error": "email_not_verified" }` for an unconfirmed account. No session is created.
+- **Passkey sign-in** follows the same rule, with the same error code.
+- **AdminCP sign-in** at `/admin` is not blocked. Only staff get in, and staff are picked by an administrator, so an install that just added an email adapter can't lock its admins out.
+- **SSO** sign-ups are confirmed already, since the provider vouches for the address.
+- **Completing a password reset** also confirms the address - the reset link went to the same mailbox.
+- **Staff** can confirm anyone by hand from the AdminCP user page.
+
+
+ Members who registered while an adapter was configured but before this feature
+ existed were never sent a link, so they are still unconfirmed. They can use
+ **Send a new confirmation link** on the sign-in form, reset their password, or
+ a staff member can confirm them from the AdminCP.
+
+
+## API routes
+
+All routes live under `/api/@vitnode/core/users`.
+
+| Method | Path | Body | Answers |
+| :----- | :--------------------- | :------------------------ | :---------------------------------------------------------- |
+| `POST` | `/verify-email` | `{ token }` | `200 { email }`, or `400` for a wrong, used or expired link |
+| `POST` | `/verify-email/resend` | `{ email }`, plus captcha | `201` for every address; `404` with no email adapter |
+
+`/verify-email/resend` answers the same `201` for an unknown address, a confirmed one and one waiting for confirmation, so it can't be used to check who has an account. It only sends when the account still needs confirming, and only once every five minutes. A new link replaces the old one.
+
+Call them through the [fetcher](/docs/dev/fetcher) like any other core route:
+
+```ts title="src/lib/confirm-email.ts"
+import { fetcher } from '@vitnode/core/tanstack/fetcher'
+
+const response = await fetcher({
+ plugin: '@vitnode/core',
+ method: 'post',
+ module: 'users',
+ path: '/verify-email',
+ args: { body: { token } },
+})
+```
+
+Confirming a link does not sign anybody in. Whoever clicks it owns the mailbox, but not necessarily the account - if a stranger registered with your address, your click must not drop you into their session.
+
+## How links are stored
+
+Links live in `core_users_confirm_emails`, one row per account:
+
+- The token is 32 random bytes, base64url-encoded. Only its SHA-256 digest is stored, so a leaked database dump can't confirm anything.
+- A link works once. Spending it deletes the row in the same statement that reads it, so two clicks racing each other can't both win.
+- The hourly `clean` [cron job](/docs/dev/cron) deletes expired rows.
+
+## Events
+
+Confirming an address emits `user.email.verified` with `{ userId, email }` - from the link, from a completed password reset, or from staff in the AdminCP. See [built-in events](/docs/dev/events/built-in-events).
+
+
diff --git a/apps/web/content/docs/dev/working-with-users/meta.json b/apps/web/content/docs/dev/working-with-users/meta.json
index 925421425e..e79ec0b403 100644
--- a/apps/web/content/docs/dev/working-with-users/meta.json
+++ b/apps/web/content/docs/dev/working-with-users/meta.json
@@ -2,5 +2,5 @@
"title": "Working with Users",
"description": "The user model, roles, and the staff permission system - how VitNode decides who somebody is and what they are allowed to do.",
"icon": "Users",
- "pages": ["users", "roles", "staff-permissions", "..."]
+ "pages": ["users", "email-verification", "roles", "staff-permissions", "..."]
}
diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json
index 5572360a7e..aedff2546f 100644
--- a/apps/web/src/locales/@vitnode/core/pl.json
+++ b/apps/web/src/locales/@vitnode/core/pl.json
@@ -47,6 +47,7 @@
"@vitnode/core:staff_moderators:can_edit": "Edytowanie uprawnień moderatorów",
"@vitnode/core:staff_moderators:can_view": "Wyświetlanie listy moderatorów",
"@vitnode/core:system": "System",
+ "@vitnode/core:system:can_manage_search": "Przebudowa i czyszczenie indeksu wyszukiwania",
"@vitnode/core:system:can_send_test_email": "Wysyłanie testowej wiadomości e-mail",
"@vitnode/core:system:can_test_ai": "Testowanie AI",
"@vitnode/core:system:can_test_storage": "Testowanie magazynu plików",
@@ -1897,6 +1898,11 @@
"access_denied": {
"desc": "Adres e-mail lub hasło są nieprawidłowe. Spróbuj ponownie i sprawdź, czy nie masz włączonego Caps Locka.",
"title": "Nieprawidłowe dane logowania"
+ },
+ "email_not_verified": {
+ "title": "Najpierw potwierdź swój e-mail",
+ "desc": "Hasło jest poprawne, ale adres e-mail tego konta nie został jeszcze potwierdzony. Kliknij link, który wysłaliśmy, i zaloguj się ponownie.",
+ "resend": "Wyślij nowy link potwierdzający"
}
},
"passkey": {
@@ -1929,6 +1935,10 @@
"not_staff": {
"title": "Brak dostępu do AdminCP",
"desc": "Klucz dostępu jest prawidłowy, ale to konto nie może otworzyć AdminCP. Jeśli uważasz, że to pomyłka, skontaktuj się z administratorem."
+ },
+ "email_not_verified": {
+ "title": "Najpierw potwierdź swój e-mail",
+ "desc": "Adres e-mail tego konta nie został jeszcze potwierdzony. Kliknij link, który wysłaliśmy, i spróbuj ponownie."
}
},
"unsupported_hint": "Ta przeglądarka nie obsługuje kluczy dostępu. Zaloguj się adresem e-mail i hasłem."
@@ -1955,7 +1965,8 @@
"email_confirmation": {
"check_spam": "Jeśli nie widzisz wiadomości w skrzynce odbiorczej, sprawdź folder ze spamem.",
"desc": "Wysłaliśmy link potwierdzający na Twój adres e-mail",
- "title": "Sprawdź swoją skrzynkę"
+ "title": "Sprawdź swoją skrzynkę",
+ "resend": "Nie dotarł? Wyślij nowy link."
},
"newsletter": {
"desc": "Otrzymuj najnowsze informacje i aktualizacje.",
@@ -2028,6 +2039,44 @@
"connection": {
"pending": "Kończymy z {provider}…"
}
+ },
+ "verify_email": {
+ "title": "Potwierdź swój e-mail",
+ "confirm": {
+ "desc": "Już prawie! Potwierdź, że to Twój adres e-mail, a Twoje konto będzie gotowe.",
+ "submit": "Potwierdź adres e-mail"
+ },
+ "success": {
+ "title": "E-mail potwierdzony",
+ "desc": "Dzięki! Adres jest potwierdzony - możesz się teraz zalogować.",
+ "sign_in": "Przejdź do logowania"
+ },
+ "invalid": {
+ "title": "Ten link już nie działa",
+ "desc": "Mógł wygasnąć lub został już użyty. Poproś o nowy, a od razu go wyślemy.",
+ "resend": "Wyślij nowy link"
+ },
+ "resend": {
+ "title": "Wyślij ponownie e-mail z potwierdzeniem",
+ "desc": "Podaj adres e-mail, którego użyłeś przy rejestracji, a wyślemy Ci nowy link potwierdzający.",
+ "submit": "Wyślij link potwierdzający",
+ "confirmation": {
+ "title": "Sprawdź swoją skrzynkę",
+ "desc": "Jeśli ten adres należy do konta czekającego na potwierdzenie, nowy link jest już w drodze na:",
+ "check_spam": "Jeśli nie widzisz wiadomości w skrzynce odbiorczej, sprawdź folder ze spamem."
+ }
+ },
+ "email": {
+ "subject": "Potwierdź swój adres e-mail",
+ "greeting": "Witaj {name}!",
+ "intro": "Dziękujemy za rejestrację! Potwierdź, że to Twój adres e-mail, aby dokończyć zakładanie konta.",
+ "button": "Potwierdź adres e-mail",
+ "instructions": "Kliknij powyższy przycisk, aby potwierdzić swój adres e-mail. Zalogujesz się, gdy tylko zostanie potwierdzony.",
+ "no_action": "Jeśli nie zakładałeś konta, możesz bezpiecznie zignorować tę wiadomość - nikt nie zaloguje się tym adresem, dopóki nie zostanie potwierdzony.",
+ "security_note": "Ze względów bezpieczeństwa: to żądanie zostało wysłane z adresu IP: {ip}",
+ "help": "Jeśli masz problem z powyższym przyciskiem, skopiuj i wklej poniższy adres URL do przeglądarki:",
+ "expire_time": "Ten link wygaśnie {date}"
+ }
}
},
"editor": {
diff --git a/apps/web/src/routes/api/$.ts b/apps/web/src/routes/api/$.ts
index d4d9915cbb..52108e7a68 100644
--- a/apps/web/src/routes/api/$.ts
+++ b/apps/web/src/routes/api/$.ts
@@ -1,10 +1,14 @@
import { createFileRoute } from '@tanstack/react-router'
+import { getRequestIP } from '@tanstack/react-start/server'
import { apiBridge } from '@/server/vitnode-api.server'
export const Route = createFileRoute('/api/$')({
server: {
handlers: ({ createHandlers }) =>
- createHandlers({ ANY: async ({ request }) => apiBridge(request) }),
+ createHandlers({
+ ANY: async ({ request }) =>
+ apiBridge(request, { clientAddress: getRequestIP() }),
+ }),
},
})
diff --git a/apps/web/src/server/api-bridge.ts b/apps/web/src/server/api-bridge.ts
index 30a6df067e..b0faf5cbb2 100644
--- a/apps/web/src/server/api-bridge.ts
+++ b/apps/web/src/server/api-bridge.ts
@@ -1,10 +1,17 @@
-export type ApiBridge = (request: Request) => Promise | Response
+export interface ApiBridgeEnv {
+ clientAddress?: string
+}
+
+export type ApiBridge = (
+ request: Request,
+ env?: ApiBridgeEnv,
+) => Promise | Response
interface FetchableApp {
- fetch: (request: Request) => Promise | Response
+ fetch: (request: Request, env?: ApiBridgeEnv) => Promise | Response
}
export const createApiBridge =
(app: FetchableApp): ApiBridge =>
- async (request) =>
- app.fetch(request)
+ async (request, env) =>
+ app.fetch(request, env)
diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/routes/api/$.ts b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/routes/api/$.ts
index 8a843120b2..a8084b102d 100644
--- a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/routes/api/$.ts
+++ b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/routes/api/$.ts
@@ -1,10 +1,14 @@
import { createFileRoute } from "@tanstack/react-router";
+import { getRequestIP } from "@tanstack/react-start/server";
import { apiBridge } from "@/server/vitnode-api.server";
export const Route = createFileRoute("/api/$")({
server: {
handlers: ({ createHandlers }) =>
- createHandlers({ ANY: async ({ request }) => apiBridge(request) }),
+ createHandlers({
+ ANY: async ({ request }) =>
+ apiBridge(request, { clientAddress: getRequestIP() }),
+ }),
},
});
diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts
index f24f783be6..82c42839ef 100644
--- a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts
+++ b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts
@@ -1,11 +1,17 @@
+export interface ApiBridgeEnv {
+ clientAddress?: string;
+}
-export type ApiBridge = (request: Request) => Promise | Response
+export type ApiBridge = (
+ request: Request,
+ env?: ApiBridgeEnv,
+) => Promise | Response;
interface FetchableApp {
- fetch: (request: Request) => Promise | Response
+ fetch: (request: Request, env?: ApiBridgeEnv) => Promise | Response;
}
export const createApiBridge =
(app: FetchableApp): ApiBridge =>
- async (request) =>
- app.fetch(request)
+ async (request, env) =>
+ app.fetch(request, env);
diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api/.gitignore_template b/packages/create-vitnode-app/copy-of-vitnode-app/api/.gitignore_template
index a6055938d1..0b346262cc 100644
--- a/packages/create-vitnode-app/copy-of-vitnode-app/api/.gitignore_template
+++ b/packages/create-vitnode-app/copy-of-vitnode-app/api/.gitignore_template
@@ -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
\ No newline at end of file
+/public/uploads
+
+# VitNode
+/docker
diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/monorepo/.gitignore_template b/packages/create-vitnode-app/copy-of-vitnode-app/monorepo/.gitignore_template
index 9df573c7b3..d7477f58a7 100644
--- a/packages/create-vitnode-app/copy-of-vitnode-app/monorepo/.gitignore_template
+++ b/packages/create-vitnode-app/copy-of-vitnode-app/monorepo/.gitignore_template
@@ -6,11 +6,8 @@ node_modules
.pnp.js
# Local env files
-.env
-.env.local
-.env.development.local
-.env.test.local
-.env.production.local
+.env*
+!.env.example
# Testing
coverage
diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/.gitignore_template b/packages/create-vitnode-app/copy-of-vitnode-app/root/.gitignore_template
index 7c19698bbd..59c94b1263 100644
--- a/packages/create-vitnode-app/copy-of-vitnode-app/root/.gitignore_template
+++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/.gitignore_template
@@ -27,15 +27,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
-.env*.local
# Local storage adapter uploads
/public/uploads
+
+# VitNode
+/docker
diff --git a/packages/elasticsearch/src/index.ts b/packages/elasticsearch/src/index.ts
index 370c0ec4ca..b5344e280b 100644
--- a/packages/elasticsearch/src/index.ts
+++ b/packages/elasticsearch/src/index.ts
@@ -12,6 +12,19 @@ import { Client, errors } from "@elastic/elasticsearch";
const DEFAULT_INDEX = "vitnode";
const DEFAULT_SIZE = 20;
const MAX_SIZE = 100;
+const MAX_SEARCH_OFFSET = 10_000;
+
+const parseSearchOffset = (
+ cursor: string | undefined,
+ max: number,
+): number | undefined => {
+ if (!cursor) return undefined;
+
+ const parsed = Number(cursor);
+ if (!Number.isSafeInteger(parsed) || parsed < 0) return undefined;
+
+ return Math.min(parsed, max);
+};
interface RankingOptions {
authorBoost?: { authorIds: number[]; weight: number };
@@ -400,7 +413,8 @@ export const ElasticsearchSearchAdapter = (
await ensureIndex();
const size = Math.min(params.first ?? DEFAULT_SIZE, MAX_SIZE);
- const from = params.cursor ? Number(params.cursor) : 0;
+ const from =
+ parseSearchOffset(params.cursor, MAX_SEARCH_OFFSET - size) ?? 0;
const res = await getClient().search({
index,
diff --git a/packages/vitnode/src/api/adapters/search/postgres.ts b/packages/vitnode/src/api/adapters/search/postgres.ts
index 92740cbc60..15530d7448 100644
--- a/packages/vitnode/src/api/adapters/search/postgres.ts
+++ b/packages/vitnode/src/api/adapters/search/postgres.ts
@@ -26,6 +26,7 @@ import type {
SearchResult,
} from "@/api/models/search";
+import { parseSearchOffset } from "@/api/lib/search-offset";
import { storageUrlOf } from "@/api/lib/storage-url";
import { core_files } from "@/database/files";
import { core_search_index, resolveSearchTextConfig } from "@/database/search";
@@ -123,16 +124,6 @@ const buildFilters = (
return conditions.length ? and(...conditions) : undefined;
};
-/**
- * The furthest into a relevance-ranked result set a caller may page.
- *
- * Relevance ordering has no stable key to seek on, so its cursor is an offset -
- * and an offset is the one pagination shape whose cost grows with the page
- * number. Nobody reaches page ten thousand of a search by reading; they reach it
- * by editing the URL.
- */
-const MAX_SEARCH_OFFSET = 10_000;
-
export const PostgresSearchAdapter = (): SearchProviderApiPlugin => ({
name: "postgres",
// Two capabilities that are both true for the same reason: this provider's
@@ -190,13 +181,7 @@ export const PostgresSearchAdapter = (): SearchProviderApiPlugin => ({
// query's `OFFSET`. `Number("abc")` is `NaN`, which Postgres rejects as a
// 500 rather than a bad request, and an unbounded one is a full scan a
// client can ask for by typing a big number into a URL.
- const parsedCursor = params.cursor ? Number(params.cursor) : undefined;
- const cursorValue =
- parsedCursor !== undefined &&
- Number.isSafeInteger(parsedCursor) &&
- parsedCursor >= 0
- ? Math.min(parsedCursor, MAX_SEARCH_OFFSET)
- : undefined;
+ const cursorValue = parseSearchOffset(params.cursor);
const orderBy: SQL[] = [];
let where = filters;
diff --git a/packages/vitnode/src/api/admin-staff-permission.test.ts b/packages/vitnode/src/api/admin-staff-permission.test.ts
new file mode 100644
index 0000000000..1de1fd4df3
--- /dev/null
+++ b/packages/vitnode/src/api/admin-staff-permission.test.ts
@@ -0,0 +1,73 @@
+// @vitest-environment node
+import { OpenAPIHono, z } from "@hono/zod-openapi";
+import { describe, expect, it } from "vitest";
+
+import { buildModule } from "./lib/module";
+import { buildRoute, findAdminRoutesWithoutStaffPermission } from "./lib/route";
+import { newBuildPluginApiCore } from "./plugin";
+
+const ALLOWLIST = [
+ "GET /admin/session",
+ "GET /admin/staff/permission-catalog",
+ "GET /admin/roles/list",
+ "POST /admin/staff/entry/:type",
+ "GET /admin/staff/entry/:type/:id",
+ "PATCH /admin/staff/entry/:type/:id",
+ "DELETE /admin/staff/entry/:type/:id",
+ "POST /admin/ai/assist",
+ "POST /admin/ai/assist/stream",
+ "POST /admin/ai/assist/estimate",
+ "GET /admin/ai/assist/available",
+ "POST /admin/ai/assist/runs/:id/feedback",
+ "GET /admin/ai/translation-sources",
+ "PUT /admin/ai/translation-sources",
+];
+
+const ok = {
+ 200: {
+ content: { "application/json": { schema: z.object({}) } },
+ description: "OK",
+ },
+};
+
+describe("findAdminRoutesWithoutStaffPermission", () => {
+ it("reports an admin route only when it has no staff permission", () => {
+ const guarded = buildRoute({
+ pluginId: "@vitnode/core",
+ adminStaffPermission: { module: "users", permission: "can_view" },
+ route: { method: "get", path: "/guarded", responses: ok },
+ handler: c => c.json({}, 200),
+ });
+ const open = buildRoute({
+ pluginId: "@vitnode/core",
+ route: { method: "get", path: "/open", responses: ok },
+ handler: c => c.json({}, 200),
+ });
+ const app = new OpenAPIHono();
+ app.route(
+ "/admin",
+ buildModule({
+ pluginId: "@vitnode/core",
+ name: "admin",
+ routes: [guarded, open],
+ }).hono,
+ );
+ app.route(
+ "/public",
+ buildModule({ pluginId: "@vitnode/core", name: "public", routes: [open] })
+ .hono,
+ );
+
+ expect(findAdminRoutesWithoutStaffPermission(app)).toEqual([
+ "GET /admin/open",
+ ]);
+ });
+});
+
+describe("core admin routes", () => {
+ it("all require a staff permission, or are explicitly allowlisted", () => {
+ expect(
+ findAdminRoutesWithoutStaffPermission(newBuildPluginApiCore.hono),
+ ).toEqual([...ALLOWLIST].sort());
+ });
+});
diff --git a/packages/vitnode/src/api/config.ts b/packages/vitnode/src/api/config.ts
index 37fcbe48bc..fff75efde2 100644
--- a/packages/vitnode/src/api/config.ts
+++ b/packages/vitnode/src/api/config.ts
@@ -6,6 +6,7 @@ import { bodyLimit } from "hono/body-limit";
import { cors } from "hono/cors";
import { csrf } from "hono/csrf";
import { HTTPException } from "hono/http-exception";
+import { secureHeaders } from "hono/secure-headers";
import type { VitNodeApiConfig } from "@/vitnode.config";
@@ -31,7 +32,10 @@ import {
globalAdminMiddleware,
globalMiddleware,
} from "./middlewares/global.middleware";
-import { rateLimiterMiddleware } from "./middlewares/rate-limiter.middleware";
+import {
+ authRateLimiterMiddleware,
+ rateLimiterMiddleware,
+} from "./middlewares/rate-limiter.middleware";
import { registerCronJobs } from "./modules/cron/helpers/register-cron-jobs";
/** 25 MB: room for an image upload, and nothing like enough to be a weapon. */
@@ -109,6 +113,13 @@ export function VitNodeAPI({
const webOrigin = CONFIG.web.origin;
+ app.use(
+ secureHeaders({
+ crossOriginResourcePolicy: false,
+ referrerPolicy: "strict-origin-when-cross-origin",
+ strictTransportSecurity: false,
+ }),
+ );
app.use(cors(corsOptions ?? { credentials: true, origin: webOrigin }));
app.use(
csrf(
@@ -132,10 +143,10 @@ export function VitNodeAPI({
onError: c => c.json({ error: "Payload Too Large" }, 413),
}),
);
- app.use(
- "*",
- rateLimiterMiddleware(vitNodeApiConfig.rateLimiter, redisClient),
- );
+ const { auth: authRateLimiter, ...rateLimiter } =
+ vitNodeApiConfig.rateLimiter ?? {};
+ app.use("*", rateLimiterMiddleware(rateLimiter, redisClient));
+ app.use("*", authRateLimiterMiddleware(authRateLimiter, redisClient));
if (docsEnabled) {
app.get("/swagger", swaggerUI({ url: "/api/swagger/doc" }));
}
diff --git a/packages/vitnode/src/api/lib/client-ip.test.ts b/packages/vitnode/src/api/lib/client-ip.test.ts
index 851cb386fc..26762ec108 100644
--- a/packages/vitnode/src/api/lib/client-ip.test.ts
+++ b/packages/vitnode/src/api/lib/client-ip.test.ts
@@ -15,9 +15,11 @@ interface Env {
}
const resolve = async ({
+ env,
headers,
socket,
}: {
+ env?: Record;
headers?: Record;
socket?: string;
}): Promise => {
@@ -28,9 +30,10 @@ const resolve = async ({
const res = await app.request(
"/",
{ headers },
- socket === undefined
- ? undefined
- : { incoming: { socket: { remoteAddress: socket } } },
+ env ??
+ (socket === undefined
+ ? undefined
+ : { incoming: { socket: { remoteAddress: socket } } }),
);
return await res.text();
@@ -66,6 +69,72 @@ describe("clientIpMiddleware", () => {
).resolves.toBe("127.0.0.1");
});
+ it("uses the address a host app hands over when it mounts the API in-process", async () => {
+ await expect(
+ resolve({ env: { clientAddress: "203.0.113.7" } }),
+ ).resolves.toBe("203.0.113.7");
+ });
+
+ describe("behind trusted proxies", () => {
+ const PROXY = "10.0.0.5";
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("takes the hop the closest trusted proxy appended", async () => {
+ vi.stubEnv("VITNODE_TRUSTED_PROXY_HOPS", "1");
+
+ await expect(
+ resolve({
+ socket: PROXY,
+ headers: { "x-forwarded-for": "9.9.9.9, 203.0.113.7" },
+ }),
+ ).resolves.toBe("203.0.113.7");
+ });
+
+ it("skips one hop per trusted proxy", async () => {
+ vi.stubEnv("VITNODE_TRUSTED_PROXY_HOPS", "2");
+
+ await expect(
+ resolve({
+ socket: PROXY,
+ headers: { "x-forwarded-for": "9.9.9.9, 203.0.113.7, 10.0.0.4" },
+ }),
+ ).resolves.toBe("203.0.113.7");
+ });
+
+ it("applies to the address a host app hands over", async () => {
+ vi.stubEnv("VITNODE_TRUSTED_PROXY_HOPS", "1");
+
+ await expect(
+ resolve({
+ env: { clientAddress: PROXY },
+ headers: { "x-forwarded-for": "203.0.113.7" },
+ }),
+ ).resolves.toBe("203.0.113.7");
+ });
+
+ it("keeps the socket when the trusted hop is not an IP address", async () => {
+ vi.stubEnv("VITNODE_TRUSTED_PROXY_HOPS", "1");
+
+ await expect(
+ resolve({
+ socket: PROXY,
+ headers: { "x-forwarded-for": "not-an-ip" },
+ }),
+ ).resolves.toBe(PROXY);
+ });
+
+ it("never reads a forwarded chain without a socket to anchor it", async () => {
+ vi.stubEnv("VITNODE_TRUSTED_PROXY_HOPS", "1");
+
+ await expect(
+ resolve({ headers: { "x-forwarded-for": "203.0.113.7" } }),
+ ).resolves.toBe("127.0.0.1");
+ });
+ });
+
describe("with a signed forwarded address from the web server", () => {
const SECRET = "a-long-random-cron-secret";
const SOCKET = "10.0.0.5";
diff --git a/packages/vitnode/src/api/lib/client-ip.ts b/packages/vitnode/src/api/lib/client-ip.ts
index 0c37e5d945..75d4b7a2f2 100644
--- a/packages/vitnode/src/api/lib/client-ip.ts
+++ b/packages/vitnode/src/api/lib/client-ip.ts
@@ -2,12 +2,16 @@ import type { Context } from "hono";
import { isIP } from "node:net";
+import { CONFIG } from "@/lib/config";
import {
FORWARDED_SIGNATURE_HEADER,
resolveForwardedIpSecret,
verifyForwardedFor,
} from "@/lib/fetcher/forwarded-signature.server";
-import { FORWARDED_IP_FALLBACK } from "@/lib/fetcher/request-context";
+import {
+ FORWARDED_IP_FALLBACK,
+ resolveVisitorIp,
+} from "@/lib/fetcher/request-context";
const UNKNOWN_CLIENT_IP = "127.0.0.1";
@@ -52,6 +56,9 @@ const denoSocketAddress = (
return isRecord(remoteAddr) ? nonEmptyString(remoteAddr.hostname) : undefined;
};
+const hostSocketAddress = (env: Record): string | undefined =>
+ nonEmptyString(env.clientAddress);
+
const socketAddress = (c: Context): string | undefined => {
const env: unknown = c.env;
if (!isRecord(env)) return undefined;
@@ -59,10 +66,21 @@ const socketAddress = (c: Context): string | undefined => {
return (
nodeSocketAddress(env) ??
bunSocketAddress(env, c.req.raw) ??
- denoSocketAddress(env)
+ denoSocketAddress(env) ??
+ hostSocketAddress(env)
);
};
+const proxiedAddress = (c: Context): string | undefined => {
+ const address = resolveVisitorIp({
+ forwardedFor: c.req.header("x-forwarded-for"),
+ socketAddress: socketAddress(c),
+ trustedProxyHops: CONFIG.trustedProxyHops,
+ });
+
+ return address && isIP(address) ? address : socketAddress(c);
+};
+
const signedForwardedAddress = (c: Context): string | undefined => {
const secret = resolveForwardedIpSecret();
const forwardedFor = c.req.header("x-forwarded-for");
@@ -79,7 +97,7 @@ const signedForwardedAddress = (c: Context): string | undefined => {
};
export const resolveClientIp = (c: Context): string =>
- signedForwardedAddress(c) ?? socketAddress(c) ?? UNKNOWN_CLIENT_IP;
+ signedForwardedAddress(c) ?? proxiedAddress(c) ?? UNKNOWN_CLIENT_IP;
export const clientIpMiddleware = async (
c: Context,
diff --git a/packages/vitnode/src/api/lib/error-details.test.ts b/packages/vitnode/src/api/lib/error-details.test.ts
index a706143f9b..6b50129e17 100644
--- a/packages/vitnode/src/api/lib/error-details.test.ts
+++ b/packages/vitnode/src/api/lib/error-details.test.ts
@@ -1,14 +1,14 @@
// @vitest-environment node
+import { DrizzleQueryError } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { describeError } from "./error-details";
-const drizzleQueryError = (sql: string, cause: unknown) => {
- const error = new Error(`Failed query: ${sql}\nparams: 26`, { cause });
- error.name = "DrizzleQueryError";
-
- return error;
-};
+const drizzleQueryError = (
+ sql: string,
+ cause: unknown,
+ params: unknown[] = [26],
+) => new DrizzleQueryError(sql, params, cause as Error);
const postgresError = (message: string, fields: Record) =>
Object.assign(new Error(message), fields);
@@ -24,7 +24,7 @@ describe("describeError", () => {
);
expect(describeError(error)).toBe(
- 'Failed query: select "example_articles"."animation" from "example_articles"\nparams: 26\n' +
+ 'Failed query: select "example_articles"."animation" from "example_articles"\nparams: [redacted]\n' +
'Caused by: column "animation" does not exist (code: 42703, position: 8)',
);
});
@@ -72,4 +72,66 @@ describe("describeError", () => {
expect(describeError(undefined)).toBe("Unknown error");
expect(describeError(null)).toBe("Unknown error");
});
+
+ it("redacts bound query parameters and keeps the SQL and the cause", () => {
+ const error = drizzleQueryError(
+ 'insert into "core_users" ("email", "password") values ($1, $2)',
+ postgresError(
+ 'duplicate key value violates unique constraint "core_users_email_unique"',
+ {
+ code: "23505",
+ detail: "Key (email)=(bob@example.com) already exists.",
+ },
+ ),
+ ["bob@example.com", "$argon2id$v=19$m=65536$hash"],
+ );
+
+ const message = describeError(error);
+
+ expect(message).toBe(
+ 'Failed query: insert into "core_users" ("email", "password") values ($1, $2)\nparams: [redacted]\n' +
+ 'Caused by: duplicate key value violates unique constraint "core_users_email_unique" (code: 23505, detail: Key (email)=([redacted]) already exists.)',
+ );
+ expect(message).not.toContain("bob@example.com");
+ expect(message).not.toContain("argon2id");
+ });
+
+ it("redacts a generated secret bound to an insert", () => {
+ const secret = "f3c1d2e4b5a6978812345678abcdef00";
+ const error = drizzleQueryError(
+ 'insert into "core_secrets" ("name", "value") values ($1, $2)',
+ new Error("connection terminated unexpectedly"),
+ ["cron", secret],
+ );
+
+ expect(describeError(error)).not.toContain(secret);
+ });
+
+ it("redacts the row Postgres echoes on a not-null violation", () => {
+ const error = postgresError(
+ 'null value in column "name" violates not-null constraint',
+ {
+ code: "23502",
+ detail: "Failing row contains (1, null, bob@example.com, $argon2id$x).",
+ },
+ );
+
+ expect(describeError(error)).toBe(
+ 'null value in column "name" violates not-null constraint (code: 23502, detail: Failing row contains ([redacted]).)',
+ );
+ });
+
+ it("redacts a query error that only survives as text", () => {
+ expect(
+ describeError("Failed query: select 1 where a = $1\nparams: hunter2"),
+ ).toBe("Failed query: select 1 where a = $1\nparams: [redacted]");
+ });
+
+ it("redacts addresses and credentials in any message", () => {
+ expect(
+ describeError(
+ new Error("rejected bob@example.com token=abc123 Bearer abc.def"),
+ ),
+ ).toBe("rejected [email] token=[redacted] Bearer [redacted]");
+ });
});
diff --git a/packages/vitnode/src/api/lib/error-details.ts b/packages/vitnode/src/api/lib/error-details.ts
index d32b598173..d8153bfb75 100644
--- a/packages/vitnode/src/api/lib/error-details.ts
+++ b/packages/vitnode/src/api/lib/error-details.ts
@@ -1,3 +1,5 @@
+import { redactSecrets } from "@/api/lib/redact";
+
const DRIVER_FIELDS = [
"code",
"detail",
@@ -11,6 +13,18 @@ const DRIVER_FIELDS = [
const MAX_CAUSE_DEPTH = 5;
+const QUERY_PARAMS = /^(Failed query: [\s\S]*?)\nparams: [\s\S]*$/;
+const DETAIL_FAILING_ROW = /Failing row contains \([\s\S]*\)/;
+const DETAIL_KEY_VALUES = /=\([\s\S]*\)/;
+
+const redactMessage = (message: string): string =>
+ redactSecrets(message.replace(QUERY_PARAMS, "$1\nparams: [redacted]"));
+
+const redactDetail = (detail: string): string =>
+ detail
+ .replace(DETAIL_FAILING_ROW, "Failing row contains ([redacted])")
+ .replace(DETAIL_KEY_VALUES, "=([redacted])");
+
const readDriverFields = (error: Error): string[] => {
const fields = error as unknown as Record;
@@ -18,7 +32,9 @@ const readDriverFields = (error: Error): string[] => {
const value = fields[key];
if (typeof value === "string" && value.length > 0) {
- return [`${key}: ${value}`];
+ const safe = key === "detail" ? redactDetail(value) : value;
+
+ return [`${key}: ${redactSecrets(safe)}`];
}
if (typeof value === "number") {
@@ -32,16 +48,17 @@ const readDriverFields = (error: Error): string[] => {
const describeOne = (error: unknown): string => {
if (error instanceof Error) {
const fields = readDriverFields(error);
- const message = error.message.length > 0 ? error.message : error.name;
+ const message =
+ error.message.length > 0 ? redactMessage(error.message) : error.name;
return fields.length > 0 ? `${message} (${fields.join(", ")})` : message;
}
if (typeof error === "string") {
- return error;
+ return redactMessage(error);
}
- return String(error);
+ return redactMessage(String(error));
};
export const describeError = (error: unknown): string => {
diff --git a/packages/vitnode/src/api/lib/image-signature.test.ts b/packages/vitnode/src/api/lib/image-signature.test.ts
new file mode 100644
index 0000000000..f79bb3390e
--- /dev/null
+++ b/packages/vitnode/src/api/lib/image-signature.test.ts
@@ -0,0 +1,62 @@
+// @vitest-environment node
+import sharp from "sharp";
+import { describe, expect, it } from "vitest";
+
+import { detectImageMimeType, isSignedImageMimeType } from "./image-signature";
+
+const encode = async (
+ format: "avif" | "gif" | "jpeg" | "png" | "tiff" | "webp",
+): Promise =>
+ await sharp({
+ create: { background: "#369", channels: 3, height: 8, width: 8 },
+ })
+ .toFormat(format)
+ .toBuffer();
+
+const ftyp = (major: string, compatible: string[]): Uint8Array => {
+ const text = `ftyp${major}\0\0\0\0${compatible.join("")}`;
+ const bytes = new Uint8Array(4 + text.length);
+ new DataView(bytes.buffer).setUint32(0, bytes.length);
+ bytes.set(new TextEncoder().encode(text), 4);
+
+ return bytes;
+};
+
+describe("detectImageMimeType", () => {
+ it.each([
+ ["avif", "image/avif"],
+ ["gif", "image/gif"],
+ ["jpeg", "image/jpeg"],
+ ["png", "image/png"],
+ ["tiff", "image/tiff"],
+ ["webp", "image/webp"],
+ ] as const)("recognises a real %s file", async (format, mimeType) => {
+ expect(detectImageMimeType(await encode(format))).toBe(mimeType);
+ });
+
+ it("finds AVIF in the compatible brands", () => {
+ expect(detectImageMimeType(ftyp("mif1", ["miaf", "avif"]))).toBe(
+ "image/avif",
+ );
+ });
+
+ it("does not take a HEIC photo for AVIF", () => {
+ expect(detectImageMimeType(ftyp("heic", ["mif1", "heic"]))).toBeNull();
+ });
+
+ it("returns null for markup, text and nothing at all", () => {
+ expect(
+ detectImageMimeType(new TextEncoder().encode("")),
+ ).toBeNull();
+ expect(detectImageMimeType(new TextEncoder().encode("hello"))).toBeNull();
+ expect(detectImageMimeType(new Uint8Array())).toBeNull();
+ });
+});
+
+describe("isSignedImageMimeType", () => {
+ it("covers raster formats and leaves SVG alone", () => {
+ expect(isSignedImageMimeType("image/png")).toBe(true);
+ expect(isSignedImageMimeType("image/svg+xml")).toBe(false);
+ expect(isSignedImageMimeType("application/pdf")).toBe(false);
+ });
+});
diff --git a/packages/vitnode/src/api/lib/image-signature.ts b/packages/vitnode/src/api/lib/image-signature.ts
new file mode 100644
index 0000000000..3030ed80ed
--- /dev/null
+++ b/packages/vitnode/src/api/lib/image-signature.ts
@@ -0,0 +1,62 @@
+export const SIGNED_IMAGE_MIME_TYPES = [
+ "image/avif",
+ "image/gif",
+ "image/jpeg",
+ "image/png",
+ "image/tiff",
+ "image/webp",
+] as const;
+
+export type SignedImageMimeType = (typeof SIGNED_IMAGE_MIME_TYPES)[number];
+
+export const isSignedImageMimeType = (
+ mimeType: string,
+): mimeType is SignedImageMimeType =>
+ (SIGNED_IMAGE_MIME_TYPES as readonly string[]).includes(mimeType);
+
+const ascii = (bytes: Uint8Array, offset: number, length: number): string =>
+ String.fromCharCode(...bytes.subarray(offset, offset + length));
+
+const readUint32BE = (bytes: Uint8Array, offset: number): number =>
+ (bytes[offset] ?? 0) * 0x1000000 +
+ ((bytes[offset + 1] ?? 0) << 16) +
+ ((bytes[offset + 2] ?? 0) << 8) +
+ (bytes[offset + 3] ?? 0);
+
+const AVIF_BRANDS = new Set(["avif", "avis"]);
+
+const isAvif = (bytes: Uint8Array): boolean => {
+ if (ascii(bytes, 4, 4) !== "ftyp") return false;
+
+ const boxSize = Math.min(readUint32BE(bytes, 0), bytes.length, 256);
+ if (AVIF_BRANDS.has(ascii(bytes, 8, 4))) return true;
+
+ for (let offset = 16; offset + 4 <= boxSize; offset += 4) {
+ if (AVIF_BRANDS.has(ascii(bytes, offset, 4))) return true;
+ }
+
+ return false;
+};
+
+export const detectImageMimeType = (
+ bytes: Uint8Array,
+): null | SignedImageMimeType => {
+ const starts = (signature: number[], offset = 0) =>
+ signature.every((byte, index) => bytes[offset + index] === byte);
+
+ if (starts([0xff, 0xd8, 0xff])) return "image/jpeg";
+ if (starts([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
+ return "image/png";
+ }
+ if (starts([0x52, 0x49, 0x46, 0x46]) && starts([0x57, 0x45, 0x42, 0x50], 8)) {
+ return "image/webp";
+ }
+ const gif = ascii(bytes, 0, 6);
+ if (gif === "GIF87a" || gif === "GIF89a") return "image/gif";
+ if (starts([0x49, 0x49, 0x2a, 0x00]) || starts([0x4d, 0x4d, 0x00, 0x2a])) {
+ return "image/tiff";
+ }
+ if (isAvif(bytes)) return "image/avif";
+
+ return null;
+};
diff --git a/packages/vitnode/src/api/lib/notifications/safe.ts b/packages/vitnode/src/api/lib/notifications/safe.ts
index eee06eb118..a79c9f77dd 100644
--- a/packages/vitnode/src/api/lib/notifications/safe.ts
+++ b/packages/vitnode/src/api/lib/notifications/safe.ts
@@ -1,3 +1,5 @@
+import { redactSecrets } from "@/api/lib/redact";
+
const MAX_TARGET_LENGTH = 2048;
const MAX_TEXT_LENGTH = 500;
const MAX_ERROR_LENGTH = 500;
@@ -42,13 +44,7 @@ export const sanitizeDeliveryError = (error: unknown): string => {
? error
: "Unknown error";
- return raw
- .replace(/[^\s@<>"']+@[^\s@<>"']+\.[^\s@<>"']+/g, "[email]")
- .replace(
- /(key|token|secret|password|authorization)=[^\s&]+/gi,
- "$1=[redacted]",
- )
- .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, "Bearer [redacted]")
+ return redactSecrets(raw)
.replace(/\s+/g, " ")
.trim()
.slice(0, MAX_ERROR_LENGTH);
diff --git a/packages/vitnode/src/api/lib/redact.ts b/packages/vitnode/src/api/lib/redact.ts
new file mode 100644
index 0000000000..19db62c363
--- /dev/null
+++ b/packages/vitnode/src/api/lib/redact.ts
@@ -0,0 +1,9 @@
+const EMAIL = /[^\s@<>"']+@[^\s@<>"']+\.[^\s@<>"']+/g;
+const CREDENTIAL_PAIR = /(key|token|secret|password|authorization)=[^\s&]+/gi;
+const BEARER = /Bearer\s+[A-Za-z0-9._~+/=-]+/g;
+
+export const redactSecrets = (text: string): string =>
+ text
+ .replace(EMAIL, "[email]")
+ .replace(CREDENTIAL_PAIR, "$1=[redacted]")
+ .replace(BEARER, "Bearer [redacted]");
diff --git a/packages/vitnode/src/api/lib/remote-image.ts b/packages/vitnode/src/api/lib/remote-image.ts
index 8b0874055a..03b717d54c 100644
--- a/packages/vitnode/src/api/lib/remote-image.ts
+++ b/packages/vitnode/src/api/lib/remote-image.ts
@@ -5,6 +5,8 @@ import { lookup as dnsLookup } from "node:dns";
import { request as httpsRequest } from "node:https";
import { BlockList, isIP } from "node:net";
+import { detectImageMimeType } from "./image-signature";
+
export const REMOTE_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
const REMOTE_IMAGE_MAX_DIMENSION = 8192;
@@ -218,18 +220,13 @@ export type RemoteImageMimeType = "image/jpeg" | "image/png" | "image/webp";
export const sniffImageMimeType = (
bytes: Uint8Array,
): null | RemoteImageMimeType => {
- const starts = (signature: number[], offset = 0) =>
- signature.every((byte, index) => bytes[offset + index] === byte);
+ const detected = detectImageMimeType(bytes);
- if (starts([0xff, 0xd8, 0xff])) return "image/jpeg";
- if (starts([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
- return "image/png";
- }
- if (starts([0x52, 0x49, 0x46, 0x46]) && starts([0x57, 0x45, 0x42, 0x50], 8)) {
- return "image/webp";
- }
-
- return null;
+ return detected === "image/jpeg" ||
+ detected === "image/png" ||
+ detected === "image/webp"
+ ? detected
+ : null;
};
const readUint16BE = (bytes: Uint8Array, offset: number) =>
diff --git a/packages/vitnode/src/api/lib/route.ts b/packages/vitnode/src/api/lib/route.ts
index de40e62edb..c2b16a03aa 100644
--- a/packages/vitnode/src/api/lib/route.ts
+++ b/packages/vitnode/src/api/lib/route.ts
@@ -16,6 +16,41 @@ export interface AdminStaffPermission {
plugin?: string;
}
+const staffPermissionGuards = new WeakSet();
+
+interface MountedRoute {
+ handler: unknown;
+ method: string;
+ path: string;
+}
+
+const unwrapHandler = (handler: unknown): unknown =>
+ typeof handler === "function" && "__COMPOSED_HANDLER" in handler
+ ? unwrapHandler(handler.__COMPOSED_HANDLER)
+ : handler;
+
+export const findAdminRoutesWithoutStaffPermission = (hono: {
+ routes: readonly MountedRoute[];
+}): string[] => {
+ const guarded = new Map();
+
+ for (const { handler, method, path } of hono.routes) {
+ if (method === "ALL" || !/(^|\/)admin(\/|$)/.test(path)) continue;
+
+ const label = `${method} ${path}`;
+ const handlerFn = unwrapHandler(handler);
+ const isGuard =
+ typeof handlerFn === "function" &&
+ staffPermissionGuards.has(handlerFn as MiddlewareHandler);
+ guarded.set(label, (guarded.get(label) ?? false) || isGuard);
+ }
+
+ return [...guarded]
+ .filter(([, isGuarded]) => !isGuarded)
+ .map(([label]) => label)
+ .sort();
+};
+
export const buildRoute = <
Plugin extends string,
P extends string,
@@ -40,7 +75,7 @@ export const buildRoute = <
if (adminStaffPermission) {
const { plugin, module, permission } = adminStaffPermission;
- middleware.push(async (c, next) => {
+ const guard: MiddlewareHandler = async (c, next) => {
await assertStaffPermission(c, {
type: "admin",
plugin: plugin ?? pluginId,
@@ -48,7 +83,9 @@ export const buildRoute = <
permission,
});
await next();
- });
+ };
+ staffPermissionGuards.add(guard);
+ middleware.push(guard);
}
if (route.withCaptcha) {
diff --git a/packages/vitnode/src/api/lib/search-offset.test.ts b/packages/vitnode/src/api/lib/search-offset.test.ts
new file mode 100644
index 0000000000..8f7961ba84
--- /dev/null
+++ b/packages/vitnode/src/api/lib/search-offset.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+
+import { MAX_SEARCH_OFFSET, parseSearchOffset } from "./search-offset";
+
+describe("parseSearchOffset", () => {
+ it("reads a plain offset", () => {
+ expect(parseSearchOffset("0")).toBe(0);
+ expect(parseSearchOffset("40")).toBe(40);
+ });
+
+ it("treats a missing cursor as the first page", () => {
+ expect(parseSearchOffset(undefined)).toBeUndefined();
+ expect(parseSearchOffset("")).toBeUndefined();
+ });
+
+ it.each(["abc", "-1", "1.5", "1e400", "NaN", "9007199254740993"])(
+ "rejects %s instead of passing it to the engine",
+ cursor => {
+ expect(parseSearchOffset(cursor)).toBeUndefined();
+ },
+ );
+
+ it("clamps a huge offset to the search window", () => {
+ expect(parseSearchOffset("99999999")).toBe(MAX_SEARCH_OFFSET);
+ });
+
+ it("clamps to a caller's own window", () => {
+ expect(parseSearchOffset("99999999", MAX_SEARCH_OFFSET - 20)).toBe(9980);
+ expect(parseSearchOffset("5", -10)).toBe(0);
+ });
+});
diff --git a/packages/vitnode/src/api/lib/search-offset.ts b/packages/vitnode/src/api/lib/search-offset.ts
new file mode 100644
index 0000000000..54ac00fe0c
--- /dev/null
+++ b/packages/vitnode/src/api/lib/search-offset.ts
@@ -0,0 +1,13 @@
+export const MAX_SEARCH_OFFSET = 10_000;
+
+export const parseSearchOffset = (
+ cursor: string | undefined,
+ max: number = MAX_SEARCH_OFFSET,
+): number | undefined => {
+ if (!cursor) return undefined;
+
+ const parsed = Number(cursor);
+ if (!Number.isSafeInteger(parsed) || parsed < 0) return undefined;
+
+ return Math.min(parsed, Math.max(0, max));
+};
diff --git a/packages/vitnode/src/api/lib/with-pagination.test.ts b/packages/vitnode/src/api/lib/with-pagination.test.ts
index 671b345535..5daed76776 100644
--- a/packages/vitnode/src/api/lib/with-pagination.test.ts
+++ b/packages/vitnode/src/api/lib/with-pagination.test.ts
@@ -180,6 +180,29 @@ describe("cursor predicate", () => {
});
});
+describe("search", () => {
+ it("escapes LIKE wildcards in the term", async () => {
+ const seen = vi.fn<
+ (args: { offset: number; where: SQL | undefined }) => Promise<[]>
+ >(async () => await Promise.resolve([]));
+
+ await withPagination({
+ c: contextWithCount(0),
+ orderBy: { column: rows.id, order: "desc" },
+ params: { query: { first: "10", search: " 100%_off " } },
+ primaryCursor: rows.id,
+ query: seen,
+ search: [rows.name],
+ table: rows,
+ });
+
+ const where = seen.mock.calls[0][0].where;
+ expect(where && dialect.sqlToQuery(where).params).toEqual([
+ "%100\\%\\_off%",
+ ]);
+ });
+});
+
describe("numbered pages", () => {
it("skips the pages before the one asked for", async () => {
expect(
diff --git a/packages/vitnode/src/api/lib/with-pagination.ts b/packages/vitnode/src/api/lib/with-pagination.ts
index 9d30c550dd..599eff9d18 100644
--- a/packages/vitnode/src/api/lib/with-pagination.ts
+++ b/packages/vitnode/src/api/lib/with-pagination.ts
@@ -29,6 +29,8 @@ import {
} from "drizzle-orm";
import { HTTPException } from "hono/http-exception";
+import { containsLikePattern } from "@/lib/api/like-pattern";
+
import type { PaginationCursor } from "./pagination-cursor";
import {
@@ -185,7 +187,9 @@ function buildSearchWhere(
const trimmed = term?.trim();
if (!search?.length || !trimmed) return undefined;
- return or(...search.map(column => ilike(column, `%${trimmed}%`)));
+ const pattern = containsLikePattern(trimmed);
+
+ return or(...search.map(column => ilike(column, pattern)));
}
async function fetchTotalCount(
diff --git a/packages/vitnode/src/api/middlewares/rate-limiter.middleware.test.ts b/packages/vitnode/src/api/middlewares/rate-limiter.middleware.test.ts
index 018f8ce9df..afd55c9641 100644
--- a/packages/vitnode/src/api/middlewares/rate-limiter.middleware.test.ts
+++ b/packages/vitnode/src/api/middlewares/rate-limiter.middleware.test.ts
@@ -1,7 +1,11 @@
import { Hono } from "hono";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { rateLimiterMiddleware } from "./rate-limiter.middleware";
+import {
+ authRateLimiterMiddleware,
+ rateLimiterMiddleware,
+ rateLimitKey,
+} from "./rate-limiter.middleware";
interface Env {
Variables: { ipAddress: string };
@@ -63,3 +67,115 @@ describe("rateLimiterMiddleware", () => {
expect(res.status).toBe(200);
});
});
+
+describe("rateLimitKey", () => {
+ it.each([
+ ["203.0.113.7", "203.0.113.7"],
+ ["::ffff:203.0.113.7", "203.0.113.7"],
+ ["2001:db8:1:2:3:4:5:6", "2001:db8:1:2::/64"],
+ ["2001:0db8:0001:0002:ffff:ffff:ffff:ffff", "2001:db8:1:2::/64"],
+ ["2001:db8::1", "2001:db8:0:0::/64"],
+ ["::1", "0:0:0:0::/64"],
+ ])("keys %s as %s", (ipAddress, key) => {
+ expect(rateLimitKey(ipAddress)).toBe(key);
+ });
+});
+
+describe("rateLimiterMiddleware keying", () => {
+ beforeEach(() => {
+ vi.stubEnv("NODE_ENV", "production");
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("shares one budget across an IPv6 /64", async () => {
+ const app = new Hono();
+ app.use("*", async (c, next) => {
+ c.set("ipAddress", c.req.header("x-test-ip") ?? "");
+
+ return next();
+ });
+ app.use("*", rateLimiterMiddleware({ points: 1, duration: 60 }));
+ app.get("/", c => c.json({ ok: true }));
+
+ const first = await app.request("/", {
+ headers: { "x-test-ip": "2001:db8:aa:bb::1" },
+ });
+ const rotated = await app.request("/", {
+ headers: { "x-test-ip": "2001:db8:aa:bb::2" },
+ });
+ const otherNetwork = await app.request("/", {
+ headers: { "x-test-ip": "2001:db8:aa:cc::1" },
+ });
+
+ expect(first.status).toBe(200);
+ expect(rotated.status).toBe(429);
+ expect(otherNetwork.status).toBe(200);
+ });
+});
+
+describe("authRateLimiterMiddleware", () => {
+ beforeEach(() => {
+ vi.stubEnv("NODE_ENV", "production");
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ const buildAuthApp = (ipAddress: string) => {
+ const app = new Hono().basePath("/api");
+ app.use("*", async (c, next) => {
+ c.set("ipAddress", ipAddress);
+
+ return next();
+ });
+ app.use("*", authRateLimiterMiddleware({ points: 2, duration: 60 }));
+ app.post("/@vitnode/core/users/sign_in", c => c.json({ ok: true }));
+ app.post("/@vitnode/core/users/passkeys/sign-in", c =>
+ c.json({ ok: true }),
+ );
+ app.get("/@vitnode/core/users/session", c => c.json({ ok: true }));
+
+ return app;
+ };
+
+ it("limits sign-in attempts with its own budget", async () => {
+ const app = buildAuthApp("auth-sign-in");
+ const statuses: number[] = [];
+ for (let attempt = 0; attempt < 3; attempt++) {
+ const res = await app.request("/api/@vitnode/core/users/sign_in", {
+ method: "POST",
+ });
+ statuses.push(res.status);
+ }
+
+ expect(statuses).toEqual([200, 200, 429]);
+ });
+
+ it("counts every credential route against the same budget", async () => {
+ const app = buildAuthApp("auth-shared");
+ await app.request("/api/@vitnode/core/users/sign_in", { method: "POST" });
+ await app.request("/api/@vitnode/core/users/passkeys/sign-in", {
+ method: "POST",
+ });
+ const res = await app.request("/api/@vitnode/core/users/sign_in", {
+ method: "POST",
+ });
+
+ expect(res.status).toBe(429);
+ });
+
+ it("leaves other routes to the global budget", async () => {
+ const app = buildAuthApp("auth-other");
+ const statuses: number[] = [];
+ for (let attempt = 0; attempt < 3; attempt++) {
+ const res = await app.request("/api/@vitnode/core/users/session");
+ statuses.push(res.status);
+ }
+
+ expect(statuses).toEqual([200, 200, 200]);
+ });
+});
diff --git a/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts b/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts
index e832f36746..c495e0cd1f 100644
--- a/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts
+++ b/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts
@@ -1,5 +1,6 @@
import type { Context, Next } from "hono";
+import { isIP } from "node:net";
import {
type IRateLimiterOptions,
type RateLimiterAbstract,
@@ -42,9 +43,38 @@ const createRateLimiter = ({
});
};
+export const rateLimitKey = (ipAddress: string): string => {
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(ipAddress);
+ if (mapped) return mapped[1];
+ if (isIP(ipAddress) !== 6) return ipAddress;
+
+ const [head, tail = ""] = ipAddress.toLowerCase().split("::");
+ const headGroups = head ? head.split(":") : [];
+ const tailGroups = tail ? tail.split(":") : [];
+ const groups = ipAddress.includes("::")
+ ? [
+ ...headGroups,
+ ...Array(8 - headGroups.length - tailGroups.length).fill("0"),
+ ...tailGroups,
+ ]
+ : headGroups;
+
+ return `${groups
+ .slice(0, 4)
+ .map(group => group.replace(/^0+(?=.)/, ""))
+ .join(":")}::/64`;
+};
+
export const rateLimiterMiddleware = (
- options?: Omit,
+ options?: Partial>,
storeClient?: CacheClient | null,
+ {
+ defaults = { duration: 60, points: 80 },
+ keyPrefix = "vitnode-api-rate-limiter",
+ }: {
+ defaults?: { duration: number; points: number };
+ keyPrefix?: string;
+ } = {},
) => {
if (CONFIG.node_development) {
// In development, we disable the rate limiter for easier testing
@@ -53,18 +83,18 @@ export const rateLimiterMiddleware = (
};
}
- const duration = options?.duration ?? 60;
+ const duration = options?.duration ?? defaults.duration;
const rateLimiter = createRateLimiter({
...options,
- keyPrefix: "vitnode-api-rate-limiter",
+ keyPrefix,
duration,
- points: options?.points ?? 80,
+ points: options?.points ?? defaults.points,
storeClient,
});
return async (c: Context, next: Next) => {
- const key = c.get("ipAddress");
+ const key = rateLimitKey(c.get("ipAddress"));
try {
await rateLimiter.consume(key);
@@ -84,3 +114,31 @@ export const rateLimiterMiddleware = (
await next();
};
};
+
+const AUTH_RATE_LIMITED_PATHS = [
+ "/@vitnode/core/users/sign_in",
+ "/@vitnode/core/users/sign_up",
+ "/@vitnode/core/users/reset-password",
+ "/@vitnode/core/users/verify-email",
+ "/@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;
+
+const isAuthRateLimitedRequest = (c: Context): boolean =>
+ c.req.method === "POST" &&
+ AUTH_RATE_LIMITED_PATHS.some(path => c.req.path.endsWith(path));
+
+export const authRateLimiterMiddleware = (
+ options?: Partial>,
+ storeClient?: CacheClient | null,
+) => {
+ const limit = rateLimiterMiddleware(options, storeClient, {
+ defaults: { duration: 60, points: 10 },
+ keyPrefix: "vitnode-api-auth-rate-limiter",
+ });
+
+ return async (c: Context, next: Next) =>
+ isAuthRateLimitedRequest(c) ? limit(c, next) : next();
+};
diff --git a/packages/vitnode/src/api/models/email-verification.ts b/packages/vitnode/src/api/models/email-verification.ts
new file mode 100644
index 0000000000..3a040f10ce
--- /dev/null
+++ b/packages/vitnode/src/api/models/email-verification.ts
@@ -0,0 +1,143 @@
+import type { Context } from "hono";
+
+import { and, eq, gt } from "drizzle-orm";
+import crypto from "node:crypto";
+import { createTranslator } from "use-intl";
+
+import { core_users, core_users_confirm_emails } from "@/database/users";
+import VerifyEmailTemplate from "@/emails/verify-email";
+import { CONFIG } from "@/lib/config";
+
+import { invalidateSessionCacheForUser } from "./session-revoke";
+
+export const EMAIL_VERIFICATION_TTL_MS = 1000 * 60 * 60 * 24;
+
+export const EMAIL_VERIFICATION_RESEND_COOLDOWN_MS = 1000 * 60 * 5;
+
+export const EMAIL_NOT_VERIFIED = "email_not_verified";
+
+export const isEmailVerificationRequired = (c: Context): boolean =>
+ Boolean(c.get("core").email?.adapter);
+
+export const mustVerifyEmail = (
+ c: Context,
+ user: { emailVerified: boolean },
+): boolean => isEmailVerificationRequired(c) && !user.emailVerified;
+
+export interface EmailVerificationRecipient {
+ email: string;
+ id: number;
+ language: string;
+ name: string;
+}
+
+export class EmailVerificationModel {
+ constructor(c: Context) {
+ this.c = c;
+ }
+
+ protected readonly c: Context;
+
+ generateToken(): string {
+ return crypto.randomBytes(32).toString("base64url");
+ }
+
+ hashToken(token: string): string {
+ return crypto.createHash("sha256").update(token).digest("hex");
+ }
+
+ async send(
+ user: EmailVerificationRecipient,
+ { respectCooldown = false }: { respectCooldown?: boolean } = {},
+ ): Promise {
+ const db = this.c.get("db");
+
+ 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));
+ const cooldownStart = Date.now() - EMAIL_VERIFICATION_RESEND_COOLDOWN_MS;
+
+ if (issued.some(row => row.createdAt.getTime() > cooldownStart)) {
+ return false;
+ }
+ }
+
+ const token = this.generateToken();
+ const expiresAt = new Date(Date.now() + EMAIL_VERIFICATION_TTL_MS);
+
+ 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({
+ userId: user.id,
+ token: this.hashToken(token),
+ expiresAt,
+ ipAddress: this.c.get("ipAddress"),
+ });
+ });
+
+ const verifyUrl = new URL(
+ `login/verify-email?token=${encodeURIComponent(token)}`,
+ CONFIG.web.href,
+ );
+
+ await this.c.get("email").send({
+ user: {
+ id: user.id,
+ email: user.email,
+ language: user.language,
+ name: user.name,
+ },
+ content: props =>
+ VerifyEmailTemplate({
+ ...props,
+ verifyUrl: verifyUrl.href,
+ expiryDate: expiresAt,
+ userIpAddress: this.c.get("ipAddress"),
+ }),
+ subject: ({ i18n }) =>
+ createTranslator(i18n)("core.auth.verify_email.email.subject"),
+ });
+
+ return true;
+ }
+
+ async verify(token: string): Promise {
+ const db = this.c.get("db");
+
+ 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 });
+
+ if (!spent) return null;
+
+ const [user] = await db
+ .update(core_users)
+ .set({ emailVerified: true })
+ .where(eq(core_users.id, spent.userId))
+ .returning({ email: core_users.email, id: core_users.id });
+
+ await db
+ .delete(core_users_confirm_emails)
+ .where(eq(core_users_confirm_emails.userId, spent.userId));
+
+ if (!user) return null;
+
+ await invalidateSessionCacheForUser(this.c, user.id);
+ await this.c.get("events").emit("user.email.verified", {
+ email: user.email,
+ userId: user.id,
+ });
+
+ return user;
+ }
+}
diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts
index 34475a4606..6c75ebb158 100644
--- a/packages/vitnode/src/api/models/events.ts
+++ b/packages/vitnode/src/api/models/events.ts
@@ -98,6 +98,10 @@ export interface VitNodeEvents {
email: string;
userId: number;
};
+ "user.email.verified": {
+ email: string;
+ userId: number;
+ };
"user.passkey.created": {
passkeyId: number;
userId: number;
diff --git a/packages/vitnode/src/api/models/storage-image.test.ts b/packages/vitnode/src/api/models/storage-image.test.ts
index 924f78382e..29539a5a05 100644
--- a/packages/vitnode/src/api/models/storage-image.test.ts
+++ b/packages/vitnode/src/api/models/storage-image.test.ts
@@ -4,12 +4,17 @@
import type { Context } from "hono";
import { HTTPException } from "hono/http-exception";
+import { crc32 } from "node:zlib";
import sharp from "sharp";
import { describe, expect, it, vi } from "vitest";
import { StorageImageUnprocessableError, StorageModel } from "./storage";
-const makeCtx = (image?: { quality?: number; webp?: boolean }) => {
+const makeCtx = (image?: {
+ maxPixels?: number;
+ quality?: number;
+ webp?: boolean;
+}) => {
const upload = vi.fn(
({ key }: { body: Buffer; contentType?: string; key: string }) => ({
key,
@@ -61,6 +66,21 @@ const makeJpeg = async (quality: number): Promise =>
const fileFrom = (buf: Buffer, name: string, type: string): File =>
new File([new Uint8Array(buf)], name, { type });
+const pngClaiming = async (width: number, height: number): Promise => {
+ const png = Buffer.from(
+ await sharp({
+ create: { background: "#fff", channels: 3, height: 1, width: 1 },
+ })
+ .png()
+ .toBuffer(),
+ );
+ png.writeUInt32BE(width, 16);
+ png.writeUInt32BE(height, 20);
+ png.writeUInt32BE(crc32(png.subarray(12, 29)), 29);
+
+ return png;
+};
+
describe("StorageModel image optimization", () => {
it("converts a processed image to WebP by default", async () => {
const original = await makeJpeg(100);
@@ -162,7 +182,7 @@ describe("StorageModel image optimization", () => {
.catch((error: unknown) => error);
};
- it("names libvips' own reason when the bytes will not decode", async () => {
+ it("refuses bytes that are not the declared format before decoding them", async () => {
const error = await failing(
Buffer.from("this is not a png at all"),
"hero.png",
@@ -173,11 +193,22 @@ describe("StorageModel image optimization", () => {
const { message, status } = error as HTTPException;
expect(status).toBe(400);
// The declared format, so a JPEG renamed `.png` reads as the mismatch it is.
- expect(message).toContain("PNG");
- expect(message).toContain("unsupported image format");
+ expect(message).toContain("not a PNG image");
expect(message).toMatch(/truncated/i);
});
+ it("names libvips' own reason when a signed file will not decode", async () => {
+ const header = Buffer.from([
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00,
+ ]);
+ const error = await failing(header, "hero.png", "image/png");
+
+ expect(error).toBeInstanceOf(HTTPException);
+ const { message, status } = error as HTTPException;
+ expect(status).toBe(400);
+ expect(message).toContain("Could not read this PNG file");
+ });
+
it("says empty rather than corrupt for an empty upload", async () => {
const error = await failing(Buffer.alloc(0), "hero.png", "image/png");
@@ -299,6 +330,117 @@ describe("StorageModel image optimization", () => {
});
});
+ describe("when the image has too many pixels", () => {
+ it("refuses an image bomb with a 400 before decoding it", async () => {
+ const { ctx, upload, insertValues } = makeCtx({ quality: 85 });
+
+ const error = await new StorageModel(ctx)
+ .upload({
+ file: fileFrom(
+ await pngClaiming(16383, 16383),
+ "bomb.png",
+ "image/png",
+ ),
+ folder: "avatars",
+ })
+ .then(() => null)
+ .catch((error: unknown) => error);
+
+ expect(error).toBeInstanceOf(StorageImageUnprocessableError);
+ expect((error as HTTPException).status).toBe(400);
+ expect((error as HTTPException).message).toContain("40,000,000 pixels");
+ expect(upload).not.toHaveBeenCalled();
+ expect(insertValues).not.toHaveBeenCalled();
+ });
+
+ it("honours a configured maxPixels", async () => {
+ const { ctx, upload } = makeCtx({ maxPixels: 10_000, quality: 85 });
+
+ const error = await new StorageModel(ctx)
+ .upload({
+ file: fileFrom(await makeJpeg(90), "photo.jpg", "image/jpeg"),
+ folder: "photos",
+ })
+ .then(() => null)
+ .catch((error: unknown) => error);
+
+ expect(error).toBeInstanceOf(StorageImageUnprocessableError);
+ expect((error as HTTPException).status).toBe(400);
+ expect((error as HTTPException).message).toContain("10,000 pixels");
+ expect(upload).not.toHaveBeenCalled();
+ });
+
+ it("accepts an image at the configured limit", async () => {
+ const { ctx, upload } = makeCtx({ maxPixels: 320 * 180, quality: 85 });
+
+ await new StorageModel(ctx).upload({
+ file: fileFrom(await makeJpeg(90), "photo.jpg", "image/jpeg"),
+ folder: "photos",
+ });
+
+ expect(upload).toHaveBeenCalledOnce();
+ });
+ });
+
+ describe("when the declared type does not match the bytes", () => {
+ const attempt = async (
+ image: Parameters[0],
+ file: File,
+ ) => {
+ const { ctx, upload } = makeCtx(image);
+ const error = await new StorageModel(ctx)
+ .upload({ file, folder: "photos" })
+ .then(() => null)
+ .catch((error: unknown) => error);
+
+ return { error, upload };
+ };
+
+ it("refuses a JPEG sent as a PNG", async () => {
+ const { error, upload } = await attempt(
+ { quality: 85 },
+ fileFrom(await makeJpeg(90), "photo.png", "image/png"),
+ );
+
+ expect(error).toBeInstanceOf(HTTPException);
+ expect((error as HTTPException).status).toBe(400);
+ expect((error as HTTPException).message).toContain(
+ "sent as PNG, but its contents are JPEG",
+ );
+ expect(upload).not.toHaveBeenCalled();
+ });
+
+ it("refuses markup sent as a GIF even with image processing off", async () => {
+ const { error, upload } = await attempt(
+ undefined,
+ fileFrom(
+ Buffer.from(""),
+ "cat.gif",
+ "image/gif",
+ ),
+ );
+
+ expect((error as HTTPException).status).toBe(400);
+ expect((error as HTTPException).message).toContain("not a GIF image");
+ expect(upload).not.toHaveBeenCalled();
+ });
+
+ it("leaves SVG to its own type", async () => {
+ const svg = Buffer.from(
+ '',
+ );
+ const { ctx, upload } = makeCtx({ quality: 85 });
+
+ await new StorageModel(ctx).upload({
+ file: fileFrom(svg, "logo.svg", "image/svg+xml"),
+ folder: "logos",
+ });
+
+ expect(upload.mock.calls[0][0].body.equals(svg)).toBe(true);
+ expect(upload.mock.calls[0][0].contentType).toBe("image/svg+xml");
+ });
+ });
+
it("leaves non-image files untouched even when image config is set", async () => {
const original = Buffer.from("just some text");
const { ctx, upload } = makeCtx({ quality: 40 });
diff --git a/packages/vitnode/src/api/models/storage.test.ts b/packages/vitnode/src/api/models/storage.test.ts
index 70d8dff33b..e730ee753a 100644
--- a/packages/vitnode/src/api/models/storage.test.ts
+++ b/packages/vitnode/src/api/models/storage.test.ts
@@ -7,6 +7,10 @@ import { core_content_file_refs } from "@/database/content";
import { STORAGE_FILE_IN_USE, StorageModel } from "./storage";
+const PNG_SIGNATURE = new Uint8Array([
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
+]);
+
const makeCtx = (
overrides: { admin?: unknown; storage?: unknown } = {},
): {
@@ -150,7 +154,7 @@ const makeDeleteCtx = (
describe("StorageModel.upload", () => {
it("uploads under {year}/{month}/{folder} with a generated file name", async () => {
const { ctx, insertValues, upload } = makeCtx();
- const file = new File(["hello"], "photo.png", { type: "image/png" });
+ const file = new File([PNG_SIGNATURE], "photo.png", { type: "image/png" });
const result = await new StorageModel(ctx).upload({
file,
@@ -180,7 +184,7 @@ describe("StorageModel.upload", () => {
it("records the admin's user id when uploaded from an admin session", async () => {
const { ctx, insertValues } = makeCtx({ admin: { user: { id: 42 } } });
- const file = new File(["hi"], "a.png", { type: "image/png" });
+ const file = new File([PNG_SIGNATURE], "a.png", { type: "image/png" });
await new StorageModel(ctx).upload({ file, folder: "avatars" });
@@ -190,7 +194,7 @@ describe("StorageModel.upload", () => {
it("uses an explicit userId over the detected session user", async () => {
const { ctx, insertValues } = makeCtx({ admin: { user: { id: 42 } } });
- const file = new File(["hi"], "a.png", { type: "image/png" });
+ const file = new File([PNG_SIGNATURE], "a.png", { type: "image/png" });
// Admin uploading on behalf of user 123 - explicit owner wins over admin (42).
await new StorageModel(ctx).upload({
@@ -204,7 +208,7 @@ describe("StorageModel.upload", () => {
it("throws when no storage provider is configured", async () => {
const { ctx, upload } = makeCtx({ storage: undefined });
- const file = new File(["hi"], "a.png", { type: "image/png" });
+ const file = new File([PNG_SIGNATURE], "a.png", { type: "image/png" });
await expect(
new StorageModel(ctx).upload({ file, folder: "avatars" }),
@@ -240,7 +244,7 @@ describe("StorageModel.upload", () => {
it("uploads when the file passes size and type validation", async () => {
const { ctx, upload } = makeCtx();
- const file = new File(["ok"], "photo.png", { type: "image/png" });
+ const file = new File([PNG_SIGNATURE], "photo.png", { type: "image/png" });
await new StorageModel(ctx).upload({
file,
diff --git a/packages/vitnode/src/api/models/storage.ts b/packages/vitnode/src/api/models/storage.ts
index 52caca9b18..4368720ac1 100644
--- a/packages/vitnode/src/api/models/storage.ts
+++ b/packages/vitnode/src/api/models/storage.ts
@@ -6,6 +6,10 @@ import { createHash } from "node:crypto";
import type { StorageFileInUseBody } from "@/lib/files/in-use";
+import {
+ detectImageMimeType,
+ isSignedImageMimeType,
+} from "@/api/lib/image-signature";
import {
findUserImageHolders,
releaseUserImageHolders,
@@ -25,6 +29,8 @@ import { enqueueAltAfterUpload } from "../lib/ai/alt";
const DEFAULT_IMAGE_QUALITY = 85;
+export const DEFAULT_IMAGE_MAX_PIXELS = 40_000_000;
+
// Formats sharp can lossily re-encode. SVG/GIF are intentionally excluded to
// preserve vectors and animation.
const PROCESSABLE_IMAGE_MIME_TYPES = new Set([
@@ -141,6 +147,42 @@ const exceedsWebpLimit = (
const imageFormatName = (mimeType: string): string =>
(mimeType.split("/")[1] ?? mimeType).toUpperCase();
+const tooManyPixels = ({
+ dimensions,
+ maxPixels,
+}: {
+ dimensions: null | { height: number; width: number };
+ maxPixels: number;
+}): StorageImageUnprocessableError => {
+ const size = dimensions
+ ? `${dimensions.width}\u00d7${dimensions.height} pixels`
+ : "larger than allowed";
+
+ return new StorageImageUnprocessableError(
+ `This image is ${size}. The maximum is ${maxPixels.toLocaleString("en-US")} pixels in total - resize it and upload it again.`,
+ );
+};
+
+const assertDeclaredImageType = (body: Buffer, mimeType: string): void => {
+ if (!isSignedImageMimeType(mimeType)) return;
+
+ const declared = imageFormatName(mimeType);
+ if (body.length === 0) {
+ throw new HTTPException(400, {
+ message: `This ${declared} file is empty.`,
+ });
+ }
+
+ const detected = detectImageMimeType(body);
+ if (detected === mimeType) return;
+
+ throw new HTTPException(400, {
+ message: detected
+ ? `This file was sent as ${declared}, but its contents are ${imageFormatName(detected)}. Save it as ${declared} or give it the right extension, then upload it again.`
+ : `This file was sent as ${declared}, but its contents are not a ${declared} image. It may be truncated, or another format saved under the wrong extension.`,
+ });
+};
+
const reasonSuffix = (error: unknown): string => {
const first =
error instanceof Error
@@ -229,6 +271,8 @@ export class StorageModel {
const quality = image.quality ?? DEFAULT_IMAGE_QUALITY;
const toWebp = image.webp !== false;
+ const maxPixels = image.maxPixels ?? DEFAULT_IMAGE_MAX_PIXELS;
+ const sharpOptions = { limitInputPixels: maxPixels };
let sharp;
try {
@@ -250,8 +294,15 @@ export class StorageModel {
// sent people off to re-export an image that was never corrupt.
let metadata;
try {
- metadata = await sharp(body).metadata();
+ metadata = await sharp(body, sharpOptions).metadata();
} catch (error) {
+ if (
+ error instanceof Error &&
+ /exceeds pixel limit/i.test(error.message)
+ ) {
+ throw tooManyPixels({ dimensions: null, maxPixels });
+ }
+
throw new HTTPException(400, {
message: `Could not read this ${imageFormatName(mimeType)} file${reasonSuffix(error)}. It may be truncated, or another format saved under the wrong extension.`,
});
@@ -272,6 +323,10 @@ export class StorageModel {
? { width: metadata.width, height: metadata.height }
: null;
+ if (dimensions && dimensions.width * dimensions.height > maxPixels) {
+ throw tooManyPixels({ dimensions, maxPixels });
+ }
+
// A 2944x16384 PNG is one pixel too tall for WebP and an entirely valid PNG,
// so it is stored as a PNG. Checked here rather than caught from the encoder
// because the right answer is not to refuse the upload: `storage.image.webp`
@@ -286,7 +341,9 @@ export class StorageModel {
let output: Buffer;
try {
- output = await sharp(body).toFormat(targetFormat, { quality }).toBuffer();
+ output = await sharp(body, sharpOptions)
+ .toFormat(targetFormat, { quality })
+ .toBuffer();
} catch (error) {
throw imageEncodeFailure({
dimensions,
@@ -481,10 +538,10 @@ export class StorageModel {
});
}
- const processed = await this.processImage(
- Buffer.from(await file.arrayBuffer()),
- file.type,
- );
+ const body = Buffer.from(await file.arrayBuffer());
+ assertDeclaredImageType(body, file.type);
+
+ const processed = await this.processImage(body, file.type);
// When a conversion changed the format, reflect the new extension in both
// the stored key and the display name so downloads and previews are honest.
diff --git a/packages/vitnode/src/api/models/user/sign-in-with-passwords.ts b/packages/vitnode/src/api/models/user/sign-in-with-passwords.ts
index 2e6579be47..7d3659ae18 100644
--- a/packages/vitnode/src/api/models/user/sign-in-with-passwords.ts
+++ b/packages/vitnode/src/api/models/user/sign-in-with-passwords.ts
@@ -21,6 +21,7 @@ export const signInWithPassword = async ({
.select({
id: core_users.id,
email: core_users.email,
+ emailVerified: core_users.emailVerified,
password: core_users.password,
})
.from(core_users)
@@ -46,5 +47,5 @@ export const signInWithPassword = async ({
throw new HTTPException(403);
}
- return { id: user.id, email: user.email };
+ return { id: user.id, email: user.email, emailVerified: user.emailVerified };
};
diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/clear-search.route.ts b/packages/vitnode/src/api/modules/admin/debug/routes/clear-search.route.ts
index a0668e12a6..cfb1e5d62e 100644
--- a/packages/vitnode/src/api/modules/admin/debug/routes/clear-search.route.ts
+++ b/packages/vitnode/src/api/modules/admin/debug/routes/clear-search.route.ts
@@ -10,7 +10,7 @@ export const zodClearSearchSchema = z.object({
export const clearSearchDebugAdminRoute = buildRoute({
pluginId: CONFIG_PLUGIN.pluginId,
- adminStaffPermission: { module: "system", permission: "can_view" },
+ adminStaffPermission: { module: "system", permission: "can_manage_search" },
route: {
method: "post",
description:
diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/rebuild-search.route.ts b/packages/vitnode/src/api/modules/admin/debug/routes/rebuild-search.route.ts
index 7e8f0516f0..c2d26e6b0e 100644
--- a/packages/vitnode/src/api/modules/admin/debug/routes/rebuild-search.route.ts
+++ b/packages/vitnode/src/api/modules/admin/debug/routes/rebuild-search.route.ts
@@ -11,7 +11,7 @@ export const zodRebuildSearchSchema = z.object({
export const rebuildSearchDebugAdminRoute = buildRoute({
pluginId: CONFIG_PLUGIN.pluginId,
- adminStaffPermission: { module: "system", permission: "can_view" },
+ adminStaffPermission: { module: "system", permission: "can_manage_search" },
route: {
method: "post",
description:
diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/search-permissions.route.test.ts b/packages/vitnode/src/api/modules/admin/debug/routes/search-permissions.route.test.ts
new file mode 100644
index 0000000000..b27bea39e6
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/debug/routes/search-permissions.route.test.ts
@@ -0,0 +1,80 @@
+// @vitest-environment node
+import type { Context } from "hono";
+
+import { OpenAPIHono } from "@hono/zod-openapi";
+import { describe, expect, it } from "vitest";
+
+import type { PermissionsStaffArgs } from "@/api/lib/permission-staff";
+
+import { createTestCache } from "@/tests/cache";
+import { grantStaffPermissions } from "@/tests/staff-permissions";
+
+import { clearSearchDebugAdminRoute } from "./clear-search.route";
+import { rebuildSearchDebugAdminRoute } from "./rebuild-search.route";
+
+const ADMIN = { id: 1, roleId: 1 };
+
+const system = (permission: string): PermissionsStaffArgs => ({
+ module: "system",
+ permission,
+ plugin: "@vitnode/core",
+});
+
+const appWith = async (permissions: PermissionsStaffArgs[]) => {
+ const cache = createTestCache();
+ await grantStaffPermissions(cache, { permissions, userId: ADMIN.id });
+
+ const app = new OpenAPIHono();
+ app.use("*", async (c, next) => {
+ c.set("admin", { user: ADMIN } as unknown as Context["var"]["admin"]);
+ c.set("cache", cache);
+ c.set("core", { searchIndexers: [] } as unknown as Context["var"]["core"]);
+ c.set("queue", {
+ dispatch: async () => await Promise.resolve(),
+ } as unknown as Context["var"]["queue"]);
+ c.set("search", {
+ clear: async () => await Promise.resolve(),
+ } as unknown as Context["var"]["search"]);
+ c.set("log", {
+ warn: async () => await Promise.resolve(),
+ } as unknown as Context["var"]["log"]);
+ await next();
+ });
+ app.openapi(
+ rebuildSearchDebugAdminRoute.route,
+ rebuildSearchDebugAdminRoute.handler,
+ );
+ app.openapi(
+ clearSearchDebugAdminRoute.route,
+ clearSearchDebugAdminRoute.handler,
+ );
+
+ return app;
+};
+
+const post = async (app: OpenAPIHono, path: string, body: object) =>
+ await app.request(path, {
+ body: JSON.stringify(body),
+ headers: { "Content-Type": "application/json" },
+ method: "POST",
+ });
+
+describe.each([
+ ["/search/rebuild", {}],
+ ["/search/clear", { itemType: "unmanaged.collection" }],
+])("POST %s", (path, body) => {
+ it("refuses an administrator who can only view the system panel", async () => {
+ const app = await appWith([system("can_view")]);
+
+ expect((await post(app, path, body)).status).toBe(403);
+ });
+
+ it("is allowed with the search management permission", async () => {
+ const app = await appWith([
+ system("can_view"),
+ system("can_manage_search"),
+ ]);
+
+ expect((await post(app, path, body)).status).toBe(200);
+ });
+});
diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts
index 5108f86ad6..edda5d6b5f 100644
--- a/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts
+++ b/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts
@@ -13,6 +13,7 @@ import { core_languages_words } from "@/database/languages";
import { core_moderators_permissions } from "@/database/moderators";
import { core_roles } from "@/database/roles";
import { core_users, core_users_secondary_roles } from "@/database/users";
+import { containsLikePattern } from "@/lib/api/like-pattern";
import { assertCanListRoles } from "../lib/assert-can-list-roles";
import { withRolesAdminListFields } from "./list-mapping";
@@ -103,7 +104,10 @@ export const listRolesAdminRoute = buildRoute({
eq(core_languages_words.tableName, "core_roles"),
eq(core_languages_words.variable, "name"),
eq(core_languages_words.pluginCode, "core"),
- ilike(core_languages_words.value, `%${search}%`),
+ ilike(
+ core_languages_words.value,
+ containsLikePattern(search),
+ ),
),
),
)
diff --git a/packages/vitnode/src/api/modules/admin/staff/lib/staff-entry-guards.ts b/packages/vitnode/src/api/modules/admin/staff/lib/staff-entry-guards.ts
index 98d241ecb4..56e2f76380 100644
--- a/packages/vitnode/src/api/modules/admin/staff/lib/staff-entry-guards.ts
+++ b/packages/vitnode/src/api/modules/admin/staff/lib/staff-entry-guards.ts
@@ -6,6 +6,7 @@ import { HTTPException } from "hono/http-exception";
import type {
PermissionsStaffArgs,
PermissionStaffType,
+ ResolvedStaffPermissionSet,
} from "@/api/lib/permission-staff";
import {
@@ -68,6 +69,47 @@ export const assertStaffAssignableRole = async (
}
};
+interface StaffEntryGrant {
+ permissions: PermissionsStaffArgs[];
+ unrestricted: boolean;
+}
+
+const exceedsCeiling = (
+ permissions: PermissionsStaffArgs[],
+ ceiling: PermissionsStaffArgs[],
+): boolean => {
+ const allowed = new Set(ceiling.map(staffPermissionKey));
+
+ return permissions.some(
+ permission => !allowed.has(staffPermissionKey(permission)),
+ );
+};
+
+export const assertCanManageStaffEntry = async (
+ c: Context,
+ { type, entry }: { entry: StaffEntryGrant; type: PermissionStaffType },
+): Promise => {
+ const ceiling = await resolveStaffPermissions(c, {
+ type,
+ user: currentAdminUser(c),
+ });
+ if (ceiling.root) return ceiling;
+
+ if (entry.unrestricted) {
+ throw new HTTPException(403, {
+ message: "Only a root administrator can manage an unrestricted entry.",
+ });
+ }
+
+ if (exceedsCeiling(entry.permissions, ceiling.permissions)) {
+ throw new HTTPException(403, {
+ message: "You cannot manage an entry that holds a permission you lack.",
+ });
+ }
+
+ return ceiling;
+};
+
export const assertWithinStaffPrivilegeCeiling = async (
c: Context,
{
@@ -75,16 +117,13 @@ export const assertWithinStaffPrivilegeCeiling = async (
current,
requested,
}: {
- current: { permissions: PermissionsStaffArgs[]; unrestricted: boolean };
- requested: { permissions: PermissionsStaffArgs[]; unrestricted: boolean };
+ current: StaffEntryGrant;
+ requested: StaffEntryGrant;
type: PermissionStaffType;
},
): Promise => {
- const ceiling = await resolveStaffPermissions(c, {
- type,
- user: currentAdminUser(c),
- });
- if (ceiling.root || current.unrestricted) return;
+ const ceiling = await assertCanManageStaffEntry(c, { type, entry: current });
+ if (ceiling.root) return;
if (requested.unrestricted) {
throw new HTTPException(403, {
@@ -92,14 +131,7 @@ export const assertWithinStaffPrivilegeCeiling = async (
});
}
- const allowed = new Set(
- [...ceiling.permissions, ...current.permissions].map(staffPermissionKey),
- );
- const exceedsCeiling = requested.permissions.some(
- permission => !allowed.has(staffPermissionKey(permission)),
- );
-
- if (exceedsCeiling) {
+ if (exceedsCeiling(requested.permissions, ceiling.permissions)) {
throw new HTTPException(403, {
message: "You cannot grant a permission you do not hold.",
});
diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/delete.route.ts b/packages/vitnode/src/api/modules/admin/staff/routes/delete.route.ts
index 26bfdc5d9a..48203cbb4c 100644
--- a/packages/vitnode/src/api/modules/admin/staff/routes/delete.route.ts
+++ b/packages/vitnode/src/api/modules/admin/staff/routes/delete.route.ts
@@ -2,10 +2,7 @@ import { z } from "@hono/zod-openapi";
import { eq } from "drizzle-orm";
import { HTTPException } from "hono/http-exception";
-import {
- assertStaffPermission,
- getUserRoleIds,
-} from "@/api/lib/check-staff-permission";
+import { assertStaffPermission } from "@/api/lib/check-staff-permission";
import { buildRoute } from "@/api/lib/route";
import { invalidateStaffEntry } from "@/api/lib/staff-permission-cache";
import { CONFIG_PLUGIN } from "@/config";
@@ -13,6 +10,10 @@ import { core_admin_permissions } from "@/database/admins";
import { core_moderators_permissions } from "@/database/moderators";
import { staffPermissionModuleByType, staffTypeSchema } from "../lib/schema";
+import {
+ assertCanManageStaffEntry,
+ assertNotOwnStaffEntry,
+} from "../lib/staff-entry-guards";
const tableByType = {
admin: core_admin_permissions,
@@ -69,6 +70,8 @@ export const deleteStaffAdminRoute = buildRoute({
protected: table.protected,
userId: table.userId,
roleId: table.roleId,
+ unrestricted: table.unrestricted,
+ permissions: table.permissions,
})
.from(table)
.where(eq(table.id, entryId))
@@ -84,19 +87,12 @@ export const deleteStaffAdminRoute = buildRoute({
// An admin cannot remove the entry that governs their own access - their own
// user entry or an entry for any role they belong to (primary or secondary).
- const currentUser = c.get("admin")?.user;
- const currentUserRoleIds = currentUser
- ? await getUserRoleIds(c, currentUser)
- : [];
- const isSelf =
- currentUser != null &&
- ((entry.userId != null && entry.userId === currentUser.id) ||
- (entry.roleId != null && currentUserRoleIds.includes(entry.roleId)));
- if (isSelf) {
- throw new HTTPException(403, {
- message: "You cannot remove your own staff permissions.",
- });
- }
+ await assertNotOwnStaffEntry(
+ c,
+ entry,
+ "You cannot remove your own staff permissions.",
+ );
+ await assertCanManageStaffEntry(c, { type, entry });
await c.get("db").delete(table).where(eq(table.id, entryId));
diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/staff-entry-routes.test.ts b/packages/vitnode/src/api/modules/admin/staff/routes/staff-entry-routes.test.ts
index 0bd2774805..5a3734d613 100644
--- a/packages/vitnode/src/api/modules/admin/staff/routes/staff-entry-routes.test.ts
+++ b/packages/vitnode/src/api/modules/admin/staff/routes/staff-entry-routes.test.ts
@@ -24,6 +24,7 @@ import {
} from "@/tests/staff-permissions";
import { createStaffAdminRoute } from "./create.route";
+import { deleteStaffAdminRoute } from "./delete.route";
import { updatePermissionsStaffAdminRoute } from "./update-permissions.route";
const CORE = "@vitnode/core";
@@ -37,10 +38,12 @@ const permission = (module: string, name: string): PermissionsStaffArgs => ({
const VIEW_ADMINS = permission("staff_admins", "can_view");
const CREATE_ADMINS = permission("staff_admins", "can_create");
const EDIT_ADMINS = permission("staff_admins", "can_edit");
-const MANAGE_ADMINS = [VIEW_ADMINS, CREATE_ADMINS, EDIT_ADMINS];
+const DELETE_ADMINS = permission("staff_admins", "can_delete");
+const MANAGE_ADMINS = [VIEW_ADMINS, CREATE_ADMINS, EDIT_ADMINS, DELETE_ADMINS];
const VIEW_USERS = permission("users", "can_view");
const EDIT_MODERATORS = permission("staff_moderators", "can_edit");
const CREATE_MODERATORS = permission("staff_moderators", "can_create");
+const DELETE_MODERATORS = permission("staff_moderators", "can_delete");
const MODERATE_WIDGETS = permission("widgets", "can_edit");
const MODERATE_USERS = permission("users", "can_edit");
@@ -52,11 +55,13 @@ const CATALOG = [
"can_view",
{ permission: "can_create", dependsOn: ["can_view"] },
{ permission: "can_edit", dependsOn: ["can_view"] },
+ { permission: "can_delete", dependsOn: ["can_view"] },
],
staff_moderators: [
"can_view",
{ permission: "can_create", dependsOn: ["can_view"] },
{ permission: "can_edit", dependsOn: ["can_view"] },
+ { permission: "can_delete", dependsOn: ["can_view"] },
],
users: ["can_view"],
}),
@@ -263,20 +268,95 @@ describe("updating a staff entry", () => {
]);
});
- it("keeps a permission the entry already had", async () => {
+ it("keeps a permission the entry already had when the caller holds it", async () => {
const { app } = await appFor(updatePermissionsStaffAdminRoute, {
- entry: entryFor({ permissions: [VIEW_USERS] }),
+ entry: entryFor({ permissions: [VIEW_ADMINS] }),
grants: { admin: MANAGE_ADMINS },
});
const res = await patch(app, {
- permissions: [VIEW_USERS, VIEW_ADMINS],
+ permissions: [VIEW_ADMINS, CREATE_ADMINS],
unrestricted: false,
});
expect(res.status).toBe(200);
});
+ it("refuses to demote an unrestricted entry from a non-root administrator", async () => {
+ const target = entryFor({ unrestricted: true });
+ const { app, storedEntries } = await appFor(
+ updatePermissionsStaffAdminRoute,
+ {
+ entry: target,
+ grants: { admin: MANAGE_ADMINS },
+ },
+ );
+
+ const res = await patch(app, { permissions: [], unrestricted: false });
+
+ expect(res.status).toBe(403);
+ expect(storedEntries()).toEqual([target]);
+ });
+
+ it("refuses to edit an entry holding a permission the caller lacks", async () => {
+ const target = entryFor({ permissions: [VIEW_USERS] });
+ const { app, storedEntries } = await appFor(
+ updatePermissionsStaffAdminRoute,
+ {
+ entry: target,
+ grants: { admin: MANAGE_ADMINS },
+ },
+ );
+
+ const res = await patch(app, { permissions: [], unrestricted: false });
+
+ expect(res.status).toBe(403);
+ expect(storedEntries()).toEqual([target]);
+ });
+
+ it("lets a root administrator demote an unrestricted entry", async () => {
+ const { app, storedEntries } = await appFor(
+ updatePermissionsStaffAdminRoute,
+ {
+ entry: entryFor({ unrestricted: true }),
+ grants: { admin: ROOT_STAFF_PERMISSIONS },
+ },
+ );
+
+ const res = await patch(app, {
+ permissions: [VIEW_ADMINS],
+ unrestricted: false,
+ });
+
+ expect(res.status).toBe(200);
+ expect(storedEntries()).toEqual([
+ expect.objectContaining({
+ permissions: [VIEW_ADMINS],
+ unrestricted: false,
+ }),
+ ]);
+ });
+
+ it("refuses to edit a moderator entry holding a moderator permission the caller lacks", async () => {
+ const target = entryFor({ permissions: [MODERATE_WIDGETS] });
+ const { app, storedEntries } = await appFor(
+ updatePermissionsStaffAdminRoute,
+ {
+ entry: target,
+ grants: { admin: [EDIT_MODERATORS], moderator: [MODERATE_USERS] },
+ },
+ );
+
+ const res = await patch(
+ app,
+ { permissions: [], unrestricted: false },
+ "moderator",
+ );
+
+ expect(res.status).toBe(403);
+ expect(storedEntries("moderator")).toEqual([target]);
+ });
+
it("measures a moderator grant against the caller's moderator permissions", async () => {
const withoutWidgets = await appFor(updatePermissionsStaffAdminRoute, {
entry: entryFor(),
@@ -323,6 +403,86 @@ describe("updating a staff entry", () => {
);
});
+const remove = async (app: OpenAPIHono, type: PermissionStaffType = "admin") =>
+ await app.request(`/entry/${type}/5`, { method: "DELETE" });
+
+describe("removing a staff entry", () => {
+ it("removes an entry within the caller's own permissions", async () => {
+ const { app, storedEntries } = await appFor(deleteStaffAdminRoute, {
+ entry: entryFor({ permissions: [VIEW_ADMINS] }),
+ grants: { admin: MANAGE_ADMINS },
+ });
+
+ const res = await remove(app);
+
+ expect(res.status).toBe(200);
+ expect(storedEntries()).toEqual([]);
+ });
+
+ it.each([
+ ["an unrestricted entry", entryFor({ unrestricted: true })],
+ [
+ "an entry holding a permission the caller lacks",
+ entryFor({ permissions: [VIEW_USERS] }),
+ ],
+ ])(
+ "refuses to remove %s from a non-root administrator",
+ async (_label, target) => {
+ const { app, storedEntries } = await appFor(deleteStaffAdminRoute, {
+ entry: target,
+ grants: { admin: MANAGE_ADMINS },
+ });
+
+ const res = await remove(app);
+
+ expect(res.status).toBe(403);
+ expect(storedEntries()).toEqual([target]);
+ },
+ );
+
+ it("lets a root administrator remove an unrestricted entry", async () => {
+ const { app, storedEntries } = await appFor(deleteStaffAdminRoute, {
+ entry: entryFor({ unrestricted: true }),
+ grants: { admin: ROOT_STAFF_PERMISSIONS },
+ });
+
+ const res = await remove(app);
+
+ expect(res.status).toBe(200);
+ expect(storedEntries()).toEqual([]);
+ });
+
+ it("measures a moderator entry against the caller's moderator permissions", async () => {
+ const target = entryFor({ permissions: [MODERATE_WIDGETS] });
+ const without = await appFor(deleteStaffAdminRoute, {
+ entry: target,
+ grants: { admin: [DELETE_MODERATORS], moderator: [MODERATE_USERS] },
+ });
+ const withWidgets = await appFor(deleteStaffAdminRoute, {
+ entry: target,
+ grants: {
+ admin: [DELETE_MODERATORS],
+ moderator: [MODERATE_USERS, MODERATE_WIDGETS],
+ },
+ });
+
+ expect((await remove(without.app, "moderator")).status).toBe(403);
+ expect(without.storedEntries("moderator")).toEqual([target]);
+ expect((await remove(withWidgets.app, "moderator")).status).toBe(200);
+ expect(withWidgets.storedEntries("moderator")).toEqual([]);
+ });
+
+ it("refuses to remove the caller's own role entry", async () => {
+ const { app, storedEntries } = await appFor(deleteStaffAdminRoute, {
+ entry: entryFor({ roleId: CALLER.roleId }),
+ grants: { admin: ROOT_STAFF_PERMISSIONS },
+ });
+
+ expect((await remove(app)).status).toBe(403);
+ expect(storedEntries()).toHaveLength(1);
+ });
+});
+
describe("creating a staff entry", () => {
it("creates an entry for an ordinary role", async () => {
const { app, storedEntries } = await appFor(createStaffAdminRoute, {
diff --git a/packages/vitnode/src/api/modules/admin/users/routes/create.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/create.route.ts
index 0ea5110b7c..03a84e6e4e 100644
--- a/packages/vitnode/src/api/modules/admin/users/routes/create.route.ts
+++ b/packages/vitnode/src/api/modules/admin/users/routes/create.route.ts
@@ -3,22 +3,28 @@ import { z } from "@hono/zod-openapi";
import { buildRoute } from "@/api/lib/route";
import { PasswordModel } from "@/api/models/password";
import { UserModel } from "@/api/models/user";
+import {
+ USER_EMAIL_MAX_LENGTH,
+ USER_NAME_MAX_LENGTH,
+ USER_PASSWORD_MAX_LENGTH,
+} from "@/api/modules/users/credential-limits";
import { CONFIG_PLUGIN } from "@/config";
const nameRegex = /^(?!.* {2})[\p{L}\p{N}._@ -]*$/u;
export const zodCreateUserAdminSchema = z.object({
- email: z.email().toLowerCase().openapi({
+ email: z.email().max(USER_EMAIL_MAX_LENGTH).toLowerCase().openapi({
example: "test@test.com",
}),
name: z
.string()
.openapi({ example: "test" })
.min(3)
+ .max(USER_NAME_MAX_LENGTH)
.refine(val => nameRegex.test(val), {
message: "Invalid name",
}),
- password: z.string().min(8).openapi({
+ password: z.string().min(8).max(USER_PASSWORD_MAX_LENGTH).openapi({
example: "Test123!",
}),
});
diff --git a/packages/vitnode/src/api/modules/admin/users/routes/password.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/password.route.ts
index 54558d73b9..b185c68eb8 100644
--- a/packages/vitnode/src/api/modules/admin/users/routes/password.route.ts
+++ b/packages/vitnode/src/api/modules/admin/users/routes/password.route.ts
@@ -5,13 +5,18 @@ import { assertPasswordSignInEnabled } from "@/api/lib/password-sign-in";
import { buildRoute } from "@/api/lib/route";
import { PasswordModel } from "@/api/models/password";
import { revokeSessions } from "@/api/models/session-revoke";
+import { USER_PASSWORD_MAX_LENGTH } from "@/api/modules/users/credential-limits";
import { CONFIG_PLUGIN } from "@/config";
import { core_users } from "@/database/users";
import { assertCanEditAdminTarget } from "../lib/assert-edit-user-permission";
export const zodSetPasswordUserAdminSchema = z.object({
- password: z.string().min(8).openapi({ example: "Test123!" }),
+ password: z
+ .string()
+ .min(8)
+ .max(USER_PASSWORD_MAX_LENGTH)
+ .openapi({ example: "Test123!" }),
});
export const setPasswordUserAdminRoute = buildRoute({
diff --git a/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts
index a7f9b63cf1..0f1d746b08 100644
--- a/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts
+++ b/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts
@@ -2,8 +2,9 @@ import { z } from "@hono/zod-openapi";
import { eq } from "drizzle-orm";
import { buildRoute } from "@/api/lib/route";
+import { invalidateSessionCacheForUser } from "@/api/models/session-revoke";
import { CONFIG_PLUGIN } from "@/config";
-import { core_users } from "@/database/users";
+import { core_users, core_users_confirm_emails } from "@/database/users";
import { assertCanEditAdminTarget } from "../lib/assert-edit-user-permission";
@@ -56,7 +57,7 @@ export const verifyEmailUserAdminRoute = buildRoute({
const db = c.get("db");
const [user] = await db
- .select({ id: core_users.id })
+ .select({ emailVerified: core_users.emailVerified, id: core_users.id })
.from(core_users)
.where(eq(core_users.id, userId))
.limit(1);
@@ -72,10 +73,21 @@ export const verifyEmailUserAdminRoute = buildRoute({
.set({ emailVerified: true })
.where(eq(core_users.id, user.id))
.returning({
+ email: core_users.email,
name: core_users.name,
emailVerified: core_users.emailVerified,
});
+ await db
+ .delete(core_users_confirm_emails)
+ .where(eq(core_users_confirm_emails.userId, user.id));
+ if (!user.emailVerified) {
+ await invalidateSessionCacheForUser(c, user.id);
+ await c
+ .get("events")
+ .emit("user.email.verified", { email: updated.email, userId: user.id });
+ }
+
return c.json(
{ name: updated.name, emailVerified: updated.emailVerified },
200,
diff --git a/packages/vitnode/src/api/modules/cron/cron/clean.cron.ts b/packages/vitnode/src/api/modules/cron/cron/clean.cron.ts
index 89bd9ec46f..d42e93bfff 100644
--- a/packages/vitnode/src/api/modules/cron/cron/clean.cron.ts
+++ b/packages/vitnode/src/api/modules/cron/cron/clean.cron.ts
@@ -10,7 +10,10 @@ import {
core_sessions,
core_sessions_known_devices,
} from "@/database/sessions";
-import { core_users_forgot_password } from "@/database/users";
+import {
+ core_users_confirm_emails,
+ core_users_forgot_password,
+} from "@/database/users";
export const isOrphanedDevice = (
db: Pick,
@@ -53,10 +56,10 @@ export const cleanCron = buildCron({
.delete(core_users_forgot_password)
.where(lt(core_users_forgot_password.expiresAt, new Date()));
- // // Delete expired email confirmation tokens
- // await tx
- // .delete(core_users_confirm_emails)
- // .where(lt(core_users_confirm_emails.expiresAt, new Date()));
+ // Delete expired email confirmation tokens
+ await tx
+ .delete(core_users_confirm_emails)
+ .where(lt(core_users_confirm_emails.expiresAt, new Date()));
});
},
});
diff --git a/packages/vitnode/src/api/modules/users/credential-limits.ts b/packages/vitnode/src/api/modules/users/credential-limits.ts
new file mode 100644
index 0000000000..88df5922ce
--- /dev/null
+++ b/packages/vitnode/src/api/modules/users/credential-limits.ts
@@ -0,0 +1,4 @@
+export const USER_EMAIL_MAX_LENGTH = 255;
+export const USER_NAME_MAX_LENGTH = 255;
+
+export const USER_PASSWORD_MAX_LENGTH = 1024;
diff --git a/packages/vitnode/src/api/modules/users/passkeys/passkeys.test.ts b/packages/vitnode/src/api/modules/users/passkeys/passkeys.test.ts
index f7ff877155..58826ccc08 100644
--- a/packages/vitnode/src/api/modules/users/passkeys/passkeys.test.ts
+++ b/packages/vitnode/src/api/modules/users/passkeys/passkeys.test.ts
@@ -94,6 +94,7 @@ const harness = ({
} = {}) => {
let passkeys = initialPasskeys;
let password = AUTHORIZATION.password;
+ let sendsEmail = false;
const memory = createMemoryPasskeyStore(accounts);
vi.spyOn(PasskeyModel.prototype, "store", "get").mockReturnValue(
memory.store,
@@ -130,6 +131,7 @@ const harness = ({
app.use("*", async (c, next) => {
c.set("core", {
authorization: { ...AUTHORIZATION, passkeys, password },
+ email: sendsEmail ? { adapter: { sendEmail: vi.fn() } } : undefined,
} as EnvVariablesVitNode["core"]);
c.set("user", viewer as unknown as Context["var"]["user"]);
c.set("db", memoryDb.db as unknown as Context["var"]["db"]);
@@ -174,6 +176,15 @@ const harness = ({
disablePasswordSignIn: () => {
password = { enabled: false };
},
+ confirmEmail: async (user: Viewer) => {
+ await memoryDb.db
+ .update(core_users)
+ .set({ emailVerified: true })
+ .where(eq(core_users.id, user.id));
+ },
+ sendEmail: () => {
+ sendsEmail = true;
+ },
grantStaff: async (user: Viewer) => {
await memoryDb.db.insert(core_admin_permissions).values({
permissions: [],
@@ -552,6 +563,28 @@ describe("passkey sign-in", () => {
expect([...h.passkeys.values()][0]?.lastUsedAt).toBeInstanceOf(Date);
});
+ it("refuses an unconfirmed email on an install that sends email", async () => {
+ const { authenticator, h } = await registered();
+ h.sendEmail();
+
+ const response = await signIn(h, authenticator);
+
+ expect(response.status).toBe(403);
+ expect(await response.json()).toEqual({ error: "email_not_verified" });
+ expect(h.createSession).not.toHaveBeenCalled();
+ });
+
+ it("signs a confirmed account in on an install that sends email", async () => {
+ const { authenticator, h } = await registered();
+ h.sendEmail();
+ await h.confirmEmail(ALICE);
+
+ const response = await signIn(h, authenticator);
+
+ expect(response.status).toBe(201);
+ expect(h.createSession).toHaveBeenCalledWith(ALICE.id);
+ });
+
it("never trusts a user id supplied by the browser", async () => {
const { authenticator, h } = await registered();
const options = await startSignIn(h);
diff --git a/packages/vitnode/src/api/modules/users/passkeys/routes/authentication-verify.route.ts b/packages/vitnode/src/api/modules/users/passkeys/routes/authentication-verify.route.ts
index 278d8be687..5f776940b6 100644
--- a/packages/vitnode/src/api/modules/users/passkeys/routes/authentication-verify.route.ts
+++ b/packages/vitnode/src/api/modules/users/passkeys/routes/authentication-verify.route.ts
@@ -1,9 +1,15 @@
import { z } from "@hono/zod-openapi";
+import { eq } from "drizzle-orm";
import { buildRoute } from "@/api/lib/route";
-import { PasskeyModel } from "@/api/models/passkey";
+import {
+ EMAIL_NOT_VERIFIED,
+ mustVerifyEmail,
+} from "@/api/models/email-verification";
+import { PasskeyError, PasskeyModel } from "@/api/models/passkey";
import { SessionModel } from "@/api/models/session";
import { CONFIG_PLUGIN } from "@/config";
+import { core_users } from "@/database/users";
import { passkeyFailure } from "../failure";
import {
@@ -50,6 +56,16 @@ export const passkeyAuthenticationVerifyRoute = buildRoute({
response,
"authentication",
);
+ const [user] = await c
+ .get("db")
+ .select({ emailVerified: core_users.emailVerified })
+ .from(core_users)
+ .where(eq(core_users.id, userId))
+ .limit(1);
+ if (user && mustVerifyEmail(c, user)) {
+ throw new PasskeyError(EMAIL_NOT_VERIFIED, 403);
+ }
+
await new SessionModel(c).createSessionByUserId(userId);
return c.json({ id: userId }, 201);
diff --git a/packages/vitnode/src/api/modules/users/passkeys/schema.ts b/packages/vitnode/src/api/modules/users/passkeys/schema.ts
index 096423db6d..d08587b9dd 100644
--- a/packages/vitnode/src/api/modules/users/passkeys/schema.ts
+++ b/packages/vitnode/src/api/modules/users/passkeys/schema.ts
@@ -108,6 +108,7 @@ export const zodPasskeyErrorSchema = z.object({
error: z.enum([
"admin_session_required",
"already_registered",
+ "email_not_verified",
"invalid_challenge",
"last_recovery_method",
"not_found",
diff --git a/packages/vitnode/src/api/modules/users/routes/change-password.route.ts b/packages/vitnode/src/api/modules/users/routes/change-password.route.ts
index 86e50d6239..7a5d6f283b 100644
--- a/packages/vitnode/src/api/modules/users/routes/change-password.route.ts
+++ b/packages/vitnode/src/api/modules/users/routes/change-password.route.ts
@@ -9,12 +9,14 @@ import { revokeSessions } from "@/api/models/session-revoke";
import { CONFIG_PLUGIN } from "@/config";
import { core_users, core_users_forgot_password } from "@/database/users";
+import { USER_PASSWORD_MAX_LENGTH } from "../credential-limits";
+
export const zodChangePasswordSchema = z.object({
- password: z.string().min(8).openapi({
+ password: z.string().min(8).max(USER_PASSWORD_MAX_LENGTH).openapi({
example: "Test123!",
}),
userId: z.number().openapi({ example: 123456 }),
- token: z.string().openapi({ example: "abcdefg12345" }),
+ token: z.string().max(512).openapi({ example: "abcdefg12345" }),
});
export const changePasswordRoute = buildRoute({
@@ -88,6 +90,20 @@ export const changePasswordRoute = buildRoute({
]);
await c.get("events").emit("user.password.updated", { userId });
+ const [confirmed] = await c
+ .get("db")
+ .update(core_users)
+ .set({ emailVerified: true })
+ .where(
+ and(eq(core_users.id, userId), eq(core_users.emailVerified, false)),
+ )
+ .returning({ email: core_users.email });
+ if (confirmed) {
+ await c
+ .get("events")
+ .emit("user.email.verified", { email: confirmed.email, userId });
+ }
+
// After the new password is in place, so a failure above cannot sign
// somebody out without having changed anything. Whoever reset this password
// is doing it because the old credential is not trusted any more, and every
diff --git a/packages/vitnode/src/api/modules/users/routes/credentials-hardening.test.ts b/packages/vitnode/src/api/modules/users/routes/credentials-hardening.test.ts
new file mode 100644
index 0000000000..34595c9a0a
--- /dev/null
+++ b/packages/vitnode/src/api/modules/users/routes/credentials-hardening.test.ts
@@ -0,0 +1,251 @@
+// @vitest-environment node
+import type { Context } from "hono";
+
+import { OpenAPIHono } from "@hono/zod-openapi";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware";
+
+import { PasswordModel } from "@/api/models/password";
+import { SessionModel } from "@/api/models/session";
+import { SessionAdminModel } from "@/api/models/session-admin";
+import { SSOModel } from "@/api/models/sso";
+import { core_roles } from "@/database/roles";
+import { core_users, core_users_forgot_password } from "@/database/users";
+import { createMemoryDb } from "@/tests/memory-db";
+import { SESSION_AUTHORIZATION } from "@/tests/sessions";
+
+import {
+ USER_EMAIL_MAX_LENGTH,
+ USER_NAME_MAX_LENGTH,
+ USER_PASSWORD_MAX_LENGTH,
+} from "../credential-limits";
+import { linkRoute } from "../sso/routes/link.route";
+import { changePasswordRoute } from "./change-password.route";
+import { resetPasswordRoute } from "./reset-passowrd.route";
+import { signInRoute } from "./sign-in.route";
+import { signUpRoute } from "./sign-up.route";
+
+const PASSWORD = "Test123!";
+const STORED_PASSWORD = await new PasswordModel().encryptPassword(PASSWORD);
+const MEMBER = {
+ avatarColor: "aaaaaa",
+ email: "test@test.com",
+ emailVerified: true,
+ id: 1,
+ ipAddress: "203.0.113.7",
+ language: "en",
+ name: "tester",
+ nameCode: "tester",
+ password: STORED_PASSWORD,
+ roleId: 2,
+};
+
+const harness = ({ withEmail = true }: { withEmail?: boolean } = {}) => {
+ const memory = createMemoryDb([
+ [
+ core_roles,
+ [
+ { default: false, id: 1, root: true },
+ { default: true, id: 2, root: false },
+ ],
+ ],
+ [core_users, [MEMBER]],
+ [core_users_forgot_password, []],
+ ]);
+ const send = vi.fn(async () => Promise.resolve());
+ vi.spyOn(SessionModel.prototype, "createSessionByUserId").mockResolvedValue({
+ token: "session-token",
+ });
+ vi.spyOn(
+ SessionAdminModel.prototype,
+ "createSessionByUserId",
+ ).mockResolvedValue({ token: "admin-token" });
+
+ const app = new OpenAPIHono();
+ app.use("*", async (c, next) => {
+ c.set("core", {
+ authorization: SESSION_AUTHORIZATION,
+ email: withEmail ? { adapter: { sendEmail: vi.fn() } } : undefined,
+ } as unknown as EnvVariablesVitNode["core"]);
+ c.set("db", memory.db as unknown as Context["var"]["db"]);
+ c.set("email", { send } as unknown as Context["var"]["email"]);
+ c.set("events", {
+ emit: vi.fn(async () => Promise.resolve(undefined)),
+ } as unknown as Context["var"]["events"]);
+ c.set("ipAddress", "203.0.113.7");
+ c.set("user", null as Context["var"]["user"]);
+ await next();
+ });
+ for (const built of [
+ changePasswordRoute,
+ resetPasswordRoute,
+ signInRoute,
+ signUpRoute,
+ ]) {
+ app.openapi(built.route, built.handler);
+ }
+ const sso = new OpenAPIHono();
+ sso.openapi(linkRoute.route, linkRoute.handler);
+ app.route("/sso", sso);
+
+ const post = async (path: string, body: unknown) =>
+ await app.request(path, {
+ body: JSON.stringify(body),
+ headers: { "content-type": "application/json" },
+ method: "POST",
+ });
+
+ return { post, send };
+};
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("the session token stays in its HttpOnly cookie", () => {
+ it("is not in a member's sign-in answer", async () => {
+ const h = harness();
+
+ const response = await h.post("/sign_in", {
+ email: MEMBER.email,
+ password: PASSWORD,
+ });
+
+ expect(response.status).toBe(201);
+ expect(await response.json()).toEqual({ id: MEMBER.id });
+ });
+
+ it("is not in an AdminCP sign-in answer", async () => {
+ const h = harness();
+
+ const response = await h.post("/sign_in", {
+ email: MEMBER.email,
+ isAdmin: true,
+ password: PASSWORD,
+ });
+
+ expect(await response.json()).toEqual({ id: MEMBER.id });
+ });
+
+ it("is not in the answer to linking an SSO identity", async () => {
+ const h = harness();
+ vi.spyOn(SSOModel.prototype, "link").mockResolvedValue({
+ userId: MEMBER.id,
+ });
+
+ const response = await h.post("/sso/google/link", {
+ password: PASSWORD,
+ token: "t".repeat(32),
+ });
+
+ expect(response.status).toBe(201);
+ expect(await response.json()).toEqual({ id: MEMBER.id });
+ });
+});
+
+describe("a password reset request says nothing about the address", () => {
+ it("answers a registered and an unknown address the same way without email", async () => {
+ const h = harness({ withEmail: false });
+
+ const known = await h.post("/reset-password", { email: MEMBER.email });
+ const unknown = await h.post("/reset-password", {
+ email: "nobody@example.com",
+ });
+
+ expect([known.status, unknown.status]).toEqual([404, 404]);
+ expect(await known.text()).toBe(await unknown.text());
+ });
+
+ it("answers a registered and an unknown address the same way with email", async () => {
+ const h = harness();
+
+ const known = await h.post("/reset-password", { email: MEMBER.email });
+ const unknown = await h.post("/reset-password", {
+ email: "nobody@example.com",
+ });
+
+ expect([known.status, unknown.status]).toEqual([201, 201]);
+ expect(await known.text()).toBe(await unknown.text());
+ expect(h.send).toHaveBeenCalledOnce();
+ });
+});
+
+describe("credential length limits", () => {
+ const tooLongPassword = "A1!".padEnd(USER_PASSWORD_MAX_LENGTH + 1, "a");
+ const tooLongEmail = `${"a".repeat(USER_EMAIL_MAX_LENGTH)}@example.com`;
+
+ it("refuses an oversized sign-in password before hashing anything", async () => {
+ const h = harness();
+ const verify = vi.spyOn(PasswordModel.prototype, "verifyPassword");
+
+ const response = await h.post("/sign_in", {
+ email: MEMBER.email,
+ password: tooLongPassword,
+ });
+
+ expect(response.status).toBe(400);
+ expect(verify).not.toHaveBeenCalled();
+ });
+
+ it("still checks a password right at the limit", async () => {
+ const h = harness();
+
+ const response = await h.post("/sign_in", {
+ email: MEMBER.email,
+ password: "a".repeat(USER_PASSWORD_MAX_LENGTH),
+ });
+
+ expect(response.status).toBe(403);
+ });
+
+ it("refuses an oversized sign-in email", async () => {
+ const h = harness();
+
+ const response = await h.post("/sign_in", {
+ email: tooLongEmail,
+ password: PASSWORD,
+ });
+
+ expect(response.status).toBe(400);
+ });
+
+ it.each([
+ ["name", { name: "n".repeat(USER_NAME_MAX_LENGTH + 1) }],
+ ["email", { email: tooLongEmail }],
+ ["password", { password: tooLongPassword }],
+ ])("refuses a sign-up with an oversized %s", async (_field, override) => {
+ const h = harness();
+ const encrypt = vi.spyOn(PasswordModel.prototype, "encryptPassword");
+
+ const response = await h.post("/sign_up", {
+ email: "fresh@example.com",
+ name: "fresh",
+ password: PASSWORD,
+ ...override,
+ });
+
+ expect(response.status).toBe(400);
+ expect(encrypt).not.toHaveBeenCalled();
+ });
+
+ it("refuses an oversized new password from a reset link", async () => {
+ const h = harness();
+
+ const response = await h.post("/change-password", {
+ password: tooLongPassword,
+ token: "a".repeat(32),
+ userId: MEMBER.id,
+ });
+
+ expect(response.status).toBe(400);
+ });
+
+ it("refuses an oversized address in a reset request", async () => {
+ const h = harness();
+
+ const response = await h.post("/reset-password", { email: tooLongEmail });
+
+ expect(response.status).toBe(400);
+ });
+});
diff --git a/packages/vitnode/src/api/modules/users/routes/email-verification.test.ts b/packages/vitnode/src/api/modules/users/routes/email-verification.test.ts
new file mode 100644
index 0000000000..8ad110a09b
--- /dev/null
+++ b/packages/vitnode/src/api/modules/users/routes/email-verification.test.ts
@@ -0,0 +1,475 @@
+// @vitest-environment node
+import type { Context } from "hono";
+
+import { OpenAPIHono } from "@hono/zod-openapi";
+import crypto from "node:crypto";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware";
+
+import {
+ EMAIL_VERIFICATION_TTL_MS,
+ EmailVerificationModel,
+} from "@/api/models/email-verification";
+import { ForgotPasswordTokenModel, PasswordModel } from "@/api/models/password";
+import { SessionModel } from "@/api/models/session";
+import { SessionAdminModel } from "@/api/models/session-admin";
+import { core_admin_sessions } from "@/database/admins";
+import { core_roles } from "@/database/roles";
+import { core_sessions } from "@/database/sessions";
+import {
+ core_users,
+ core_users_confirm_emails,
+ core_users_forgot_password,
+} from "@/database/users";
+import { createTestCache } from "@/tests/cache";
+import { createMemoryDb } from "@/tests/memory-db";
+import { SESSION_AUTHORIZATION } from "@/tests/sessions";
+
+import { changePasswordRoute } from "./change-password.route";
+import { resendVerificationRoute } from "./resend-verification.route";
+import { resetPasswordRoute } from "./reset-passowrd.route";
+import { signInRoute } from "./sign-in.route";
+import { signUpRoute } from "./sign-up.route";
+import { verifyEmailRoute } from "./verify-email.route";
+
+const PASSWORD = "Test123!";
+const STORED_PASSWORD = await new PasswordModel().encryptPassword(PASSWORD);
+const LINK_TOKEN = "confirm-token-confirm-token-confirm-token";
+const sha256 = (value: string) =>
+ crypto.createHash("sha256").update(value).digest("hex");
+
+interface MemberSeed {
+ email: string;
+ emailVerified: boolean;
+ id: number;
+}
+
+const member = ({ email, emailVerified, id }: MemberSeed) => ({
+ avatarColor: "aaaaaa",
+ email,
+ emailVerified,
+ id,
+ ipAddress: "203.0.113.7",
+ language: "en",
+ name: `member-${id}`,
+ nameCode: `member-${id}`,
+ password: STORED_PASSWORD,
+ roleId: 2,
+});
+
+const UNVERIFIED = { email: "new@example.com", emailVerified: false, id: 7 };
+const VERIFIED = { email: "old@example.com", emailVerified: true, id: 8 };
+
+const harness = ({
+ confirmations = [],
+ members = [UNVERIFIED, VERIFIED],
+ resets = [],
+ withEmail = true,
+}: {
+ confirmations?: Record[];
+ members?: MemberSeed[];
+ resets?: Record[];
+ withEmail?: boolean;
+} = {}) => {
+ const memory = createMemoryDb([
+ [
+ core_roles,
+ [
+ { default: false, id: 1, root: true },
+ { default: true, id: 2, root: false },
+ ],
+ ],
+ [core_users, members.map(member)],
+ [core_users_confirm_emails, confirmations],
+ [core_users_forgot_password, resets],
+ [core_sessions, []],
+ [core_admin_sessions, []],
+ ]);
+ const send = vi.fn(async () => Promise.resolve());
+ const emit = vi.fn(async () => Promise.resolve(undefined));
+ const logError = vi.fn(async () => Promise.resolve());
+ const createSession = vi
+ .spyOn(SessionModel.prototype, "createSessionByUserId")
+ .mockResolvedValue({ token: "session-token" });
+ const createAdminSession = vi
+ .spyOn(SessionAdminModel.prototype, "createSessionByUserId")
+ .mockResolvedValue({ token: "admin-token" });
+ vi.spyOn(EmailVerificationModel.prototype, "generateToken").mockReturnValue(
+ LINK_TOKEN,
+ );
+
+ const app = new OpenAPIHono();
+ app.use("*", async (c, next) => {
+ c.set("core", {
+ authorization: SESSION_AUTHORIZATION,
+ email: withEmail ? { adapter: { sendEmail: vi.fn() } } : undefined,
+ } as unknown as EnvVariablesVitNode["core"]);
+ c.set("cache", createTestCache());
+ c.set("db", memory.db as unknown as Context["var"]["db"]);
+ c.set("email", { send } as unknown as Context["var"]["email"]);
+ c.set("events", { emit } as unknown as Context["var"]["events"]);
+ c.set("ipAddress", "203.0.113.7");
+ c.set("log", { error: logError } as unknown as Context["var"]["log"]);
+ c.set("user", null as Context["var"]["user"]);
+ await next();
+ });
+ for (const built of [
+ changePasswordRoute,
+ resendVerificationRoute,
+ resetPasswordRoute,
+ signInRoute,
+ signUpRoute,
+ verifyEmailRoute,
+ ]) {
+ app.openapi(built.route, built.handler);
+ }
+
+ const post = async (path: string, body: unknown) =>
+ await app.request(path, {
+ body: JSON.stringify(body),
+ headers: { "content-type": "application/json" },
+ method: "POST",
+ });
+
+ const userRow = (id: number) =>
+ memory.rows(core_users).find(row => row.id === id);
+ const confirmationRows = () => memory.rows(core_users_confirm_emails);
+
+ return {
+ confirmationRows,
+ createAdminSession,
+ createSession,
+ emit,
+ logError,
+ post,
+ send,
+ userRow,
+ };
+};
+
+const pendingLink = (overrides: Record = {}) => ({
+ createdAt: new Date(Date.now() - 1000 * 60 * 60),
+ expiresAt: new Date(Date.now() + 1000 * 60 * 60),
+ id: 1,
+ ipAddress: "203.0.113.7",
+ token: sha256(LINK_TOKEN),
+ userId: UNVERIFIED.id,
+ ...overrides,
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("signing up on an install that sends email", () => {
+ const SIGN_UP = {
+ email: "fresh@example.com",
+ name: "fresh",
+ password: PASSWORD,
+ };
+
+ it("emails a confirmation link instead of signing the member in", async () => {
+ const h = harness({ members: [VERIFIED] });
+
+ const response = await h.post("/sign_up", SIGN_UP);
+
+ expect(response.status).toBe(201);
+ expect(await response.json()).toMatchObject({
+ email: SIGN_UP.email,
+ emailVerified: false,
+ });
+ expect(h.createSession).not.toHaveBeenCalled();
+ expect(h.send).toHaveBeenCalledOnce();
+ expect(h.send).toHaveBeenCalledWith(
+ expect.objectContaining({
+ user: expect.objectContaining({ email: SIGN_UP.email }),
+ }),
+ );
+ });
+
+ it("stores only a digest of the link, valid for a day", async () => {
+ const h = harness({ members: [VERIFIED] });
+
+ await h.post("/sign_up", SIGN_UP);
+
+ const [row] = h.confirmationRows();
+ expect(row?.token).toBe(sha256(LINK_TOKEN));
+ expect(row?.token).not.toBe(LINK_TOKEN);
+ expect(row?.expiresAt).toBeInstanceOf(Date);
+ const expiresIn = Number(row?.expiresAt) - Date.now();
+ expect(expiresIn).toBeGreaterThan(EMAIL_VERIFICATION_TTL_MS - 60_000);
+ expect(expiresIn).toBeLessThanOrEqual(EMAIL_VERIFICATION_TTL_MS);
+ });
+
+ it("still creates the account when the link could not be sent", async () => {
+ const h = harness({ members: [VERIFIED] });
+ h.send.mockRejectedValueOnce(new Error("smtp down"));
+
+ const response = await h.post("/sign_up", SIGN_UP);
+
+ expect(response.status).toBe(201);
+ expect(h.logError).toHaveBeenCalledOnce();
+ });
+
+ it("signs the member straight in when the install cannot send email", async () => {
+ const h = harness({ members: [VERIFIED], withEmail: false });
+
+ const response = await h.post("/sign_up", SIGN_UP);
+
+ expect(await response.json()).toMatchObject({ emailVerified: true });
+ expect(h.createSession).toHaveBeenCalledOnce();
+ expect(h.send).not.toHaveBeenCalled();
+ });
+});
+
+describe("signing in before the address is confirmed", () => {
+ it("refuses the right password with email_not_verified", async () => {
+ const h = harness();
+
+ const response = await h.post("/sign_in", {
+ email: UNVERIFIED.email,
+ password: PASSWORD,
+ });
+
+ expect(response.status).toBe(403);
+ expect(await response.json()).toEqual({ error: "email_not_verified" });
+ expect(h.createSession).not.toHaveBeenCalled();
+ });
+
+ it("answers a wrong password exactly like an unknown address", async () => {
+ const h = harness();
+
+ const wrongPassword = await h.post("/sign_in", {
+ email: UNVERIFIED.email,
+ password: "Wrong123!",
+ });
+ const unknown = await h.post("/sign_in", {
+ email: "nobody@example.com",
+ password: "Wrong123!",
+ });
+
+ expect(wrongPassword.status).toBe(403);
+ expect(unknown.status).toBe(403);
+ expect(await wrongPassword.text()).toBe(await unknown.text());
+ });
+
+ it("proves the password before saying anything about the address", async () => {
+ const h = harness();
+ const verify = vi.spyOn(PasswordModel.prototype, "verifyPassword");
+
+ await h.post("/sign_in", { email: UNVERIFIED.email, password: PASSWORD });
+
+ expect(verify).toHaveBeenCalledWith(PASSWORD, STORED_PASSWORD);
+ });
+
+ it("lets the same member in once the install no longer sends email", async () => {
+ const h = harness({ withEmail: false });
+
+ const response = await h.post("/sign_in", {
+ email: UNVERIFIED.email,
+ password: PASSWORD,
+ });
+
+ expect(response.status).toBe(201);
+ expect(h.createSession).toHaveBeenCalledWith(UNVERIFIED.id);
+ });
+
+ it("does not hold up the AdminCP sign-in, which only admits staff", async () => {
+ const h = harness();
+
+ const response = await h.post("/sign_in", {
+ email: UNVERIFIED.email,
+ isAdmin: true,
+ password: PASSWORD,
+ });
+
+ expect(response.status).toBe(201);
+ expect(h.createAdminSession).toHaveBeenCalledWith(UNVERIFIED.id);
+ });
+});
+
+describe("following the confirmation link", () => {
+ it("confirms the address, spends the link and emits user.email.verified", async () => {
+ const h = harness({ confirmations: [pendingLink()] });
+
+ const response = await h.post("/verify-email", { token: LINK_TOKEN });
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({ email: UNVERIFIED.email });
+ expect(h.userRow(UNVERIFIED.id)?.emailVerified).toBe(true);
+ expect(h.confirmationRows()).toEqual([]);
+ expect(h.emit).toHaveBeenCalledWith("user.email.verified", {
+ email: UNVERIFIED.email,
+ userId: UNVERIFIED.id,
+ });
+ });
+
+ it("does not sign anybody in - the member signs in with their password next", async () => {
+ const h = harness({ confirmations: [pendingLink()] });
+
+ const confirmed = await h.post("/verify-email", { token: LINK_TOKEN });
+ const signIn = await h.post("/sign_in", {
+ email: UNVERIFIED.email,
+ password: PASSWORD,
+ });
+
+ expect(confirmed.headers.getSetCookie()).toEqual([]);
+ expect(signIn.status).toBe(201);
+ expect(h.createSession).toHaveBeenCalledOnce();
+ });
+
+ it("works exactly once", async () => {
+ const h = harness({ confirmations: [pendingLink()] });
+
+ const first = await h.post("/verify-email", { token: LINK_TOKEN });
+ const second = await h.post("/verify-email", { token: LINK_TOKEN });
+
+ expect(first.status).toBe(200);
+ expect(second.status).toBe(400);
+ expect(h.emit).toHaveBeenCalledOnce();
+ });
+
+ it("refuses an expired link and leaves the account unconfirmed", async () => {
+ const h = harness({
+ confirmations: [pendingLink({ expiresAt: new Date(Date.now() - 1000) })],
+ });
+
+ const response = await h.post("/verify-email", { token: LINK_TOKEN });
+
+ expect(response.status).toBe(400);
+ expect(h.userRow(UNVERIFIED.id)?.emailVerified).toBe(false);
+ });
+
+ it("refuses a token that was never issued", async () => {
+ const h = harness({ confirmations: [pendingLink()] });
+
+ const response = await h.post("/verify-email", {
+ token: "never-issued-never-issued-never-issued",
+ });
+
+ expect(response.status).toBe(400);
+ expect(h.userRow(UNVERIFIED.id)?.emailVerified).toBe(false);
+ });
+});
+
+describe("asking for a new confirmation link", () => {
+ it("answers the same 201 for an unknown, a confirmed and an unconfirmed address", async () => {
+ const h = harness();
+
+ const responses = await Promise.all(
+ ["nobody@example.com", VERIFIED.email, UNVERIFIED.email].map(
+ async email => await h.post("/verify-email/resend", { email }),
+ ),
+ );
+
+ expect(responses.map(response => response.status)).toEqual([201, 201, 201]);
+ const bodies = await Promise.all(
+ responses.map(async response => await response.text()),
+ );
+ expect(new Set(bodies).size).toBe(1);
+ });
+
+ it("only sends to an account that still needs confirming", async () => {
+ const h = harness();
+
+ await h.post("/verify-email/resend", { email: "nobody@example.com" });
+ await h.post("/verify-email/resend", { email: VERIFIED.email });
+ await h.post("/verify-email/resend", { email: UNVERIFIED.email });
+
+ expect(h.send).toHaveBeenCalledOnce();
+ expect(h.send).toHaveBeenCalledWith(
+ expect.objectContaining({
+ user: expect.objectContaining({ id: UNVERIFIED.id }),
+ }),
+ );
+ });
+
+ it("replaces the old link, so only the newest one confirms", async () => {
+ const h = harness({
+ confirmations: [
+ pendingLink({ token: sha256("an-older-link-an-older-link") }),
+ ],
+ });
+
+ await h.post("/verify-email/resend", { email: UNVERIFIED.email });
+
+ expect(h.confirmationRows().map(row => row.token)).toEqual([
+ sha256(LINK_TOKEN),
+ ]);
+ expect(
+ (await h.post("/verify-email", { token: "an-older-link-an-older-link" }))
+ .status,
+ ).toBe(400);
+ });
+
+ it("sends nothing while a link from the last five minutes is still out", async () => {
+ const h = harness({
+ confirmations: [pendingLink({ createdAt: new Date() })],
+ });
+
+ const response = await h.post("/verify-email/resend", {
+ email: UNVERIFIED.email,
+ });
+
+ expect(response.status).toBe(201);
+ expect(h.send).not.toHaveBeenCalled();
+ });
+
+ it("answers 404 for every address on an install that cannot send email", async () => {
+ const h = harness({ withEmail: false });
+
+ const known = await h.post("/verify-email/resend", {
+ email: UNVERIFIED.email,
+ });
+ const unknown = await h.post("/verify-email/resend", {
+ email: "nobody@example.com",
+ });
+
+ expect([known.status, unknown.status]).toEqual([404, 404]);
+ expect(await known.text()).toBe(await unknown.text());
+ });
+});
+
+describe("completing a password reset", () => {
+ const RESET_TOKEN = "reset-token-reset-token";
+ const resetFor = (userId: number) => ({
+ expiresAt: new Date(Date.now() + 1000 * 60 * 10),
+ id: userId,
+ ipAddress: "203.0.113.7",
+ token: new ForgotPasswordTokenModel().hashResetToken(RESET_TOKEN),
+ userId,
+ });
+ const changePassword = (userId: number) => ({
+ password: "NewPassword1!",
+ token: RESET_TOKEN,
+ userId,
+ });
+
+ it("also confirms the address, since the reset link went to the same mailbox", async () => {
+ const h = harness({ resets: [resetFor(UNVERIFIED.id)] });
+
+ const response = await h.post(
+ "/change-password",
+ changePassword(UNVERIFIED.id),
+ );
+
+ expect(response.status).toBe(201);
+ expect(h.userRow(UNVERIFIED.id)?.emailVerified).toBe(true);
+ expect(h.emit).toHaveBeenCalledWith("user.email.verified", {
+ email: UNVERIFIED.email,
+ userId: UNVERIFIED.id,
+ });
+ });
+
+ it("emits nothing about the address when it was already confirmed", async () => {
+ const h = harness({ resets: [resetFor(VERIFIED.id)] });
+
+ await h.post("/change-password", changePassword(VERIFIED.id));
+
+ expect(h.emit).not.toHaveBeenCalledWith(
+ "user.email.verified",
+ expect.anything(),
+ );
+ });
+});
diff --git a/packages/vitnode/src/api/modules/users/routes/resend-verification.route.ts b/packages/vitnode/src/api/modules/users/routes/resend-verification.route.ts
new file mode 100644
index 0000000000..db3e0878c1
--- /dev/null
+++ b/packages/vitnode/src/api/modules/users/routes/resend-verification.route.ts
@@ -0,0 +1,82 @@
+import { HTTPException } from "hono/http-exception";
+import { z } from "zod";
+
+import { buildRoute } from "@/api/lib/route";
+import { matchesEmail, pickAccountForEmail } from "@/api/lib/user-email-lookup";
+import {
+ EmailVerificationModel,
+ isEmailVerificationRequired,
+} from "@/api/models/email-verification";
+import { CONFIG_PLUGIN } from "@/config";
+import { core_users } from "@/database/users";
+
+import { USER_EMAIL_MAX_LENGTH } from "../credential-limits";
+
+export const resendVerificationRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ route: {
+ method: "post",
+ description:
+ "Email a fresh confirmation link. Answers the same for every address, registered or not.",
+ path: "/verify-email/resend",
+ withCaptcha: true,
+ request: {
+ body: {
+ required: true,
+ content: {
+ "application/json": {
+ schema: z.object({
+ email: z
+ .email()
+ .max(USER_EMAIL_MAX_LENGTH)
+ .toLowerCase()
+ .openapi({
+ example: "test@test.com",
+ }),
+ }),
+ },
+ },
+ },
+ },
+ responses: {
+ 201: {
+ description: "Email sent, if there was anything to send",
+ },
+ 404: {
+ description:
+ "This install cannot send email, so there is nothing to confirm",
+ },
+ },
+ },
+ handler: async c => {
+ if (!isEmailVerificationRequired(c)) {
+ throw new HTTPException(404, {
+ message: "Email verification is not available",
+ });
+ }
+
+ const RESPONSE_TEXT = c.text("Email sent", 201);
+ const { email } = c.req.valid("json");
+ const candidates = await c
+ .get("db")
+ .select({
+ email: core_users.email,
+ emailVerified: core_users.emailVerified,
+ id: core_users.id,
+ language: core_users.language,
+ name: core_users.name,
+ })
+ .from(core_users)
+ .where(matchesEmail(email))
+ .limit(2);
+ const user = pickAccountForEmail(candidates, email);
+
+ if (!user || user.emailVerified) {
+ return RESPONSE_TEXT;
+ }
+
+ await new EmailVerificationModel(c).send(user, { respectCooldown: true });
+
+ return RESPONSE_TEXT;
+ },
+});
diff --git a/packages/vitnode/src/api/modules/users/routes/reset-passowrd.route.ts b/packages/vitnode/src/api/modules/users/routes/reset-passowrd.route.ts
index 93e66a5cf3..c3636fd021 100644
--- a/packages/vitnode/src/api/modules/users/routes/reset-passowrd.route.ts
+++ b/packages/vitnode/src/api/modules/users/routes/reset-passowrd.route.ts
@@ -1,4 +1,5 @@
import { eq } from "drizzle-orm";
+import { HTTPException } from "hono/http-exception";
import { createTranslator } from "use-intl";
import { z } from "zod";
@@ -11,6 +12,8 @@ import { core_users, core_users_forgot_password } from "@/database/users";
import ResetPasswordEmailTemplate from "@/emails/reset-password";
import { CONFIG } from "@/lib/config";
+import { USER_EMAIL_MAX_LENGTH } from "../credential-limits";
+
export const resetPasswordRoute = buildRoute({
pluginId: CONFIG_PLUGIN.pluginId,
route: {
@@ -24,9 +27,13 @@ export const resetPasswordRoute = buildRoute({
content: {
"application/json": {
schema: z.object({
- email: z.email().toLowerCase().openapi({
- example: "test@test.com",
- }),
+ email: z
+ .email()
+ .max(USER_EMAIL_MAX_LENGTH)
+ .toLowerCase()
+ .openapi({
+ example: "test@test.com",
+ }),
}),
},
},
@@ -34,12 +41,22 @@ export const resetPasswordRoute = buildRoute({
},
responses: {
201: {
- description: "Email sent",
+ description: "Email sent, if the address belongs to an account",
+ },
+ 404: {
+ description: "This install cannot send email, so it offers no reset",
},
},
},
handler: async c => {
assertPasswordSignInEnabled(c);
+
+ if (!c.get("core").email?.adapter) {
+ throw new HTTPException(404, {
+ message: "Password reset is not available",
+ });
+ }
+
const RESPONSE_TEXT = c.text("Email sent", 201);
const { email } = c.req.valid("json");
const candidates = await c
diff --git a/packages/vitnode/src/api/modules/users/routes/sign-in.route.ts b/packages/vitnode/src/api/modules/users/routes/sign-in.route.ts
index aeed1bee5e..b07c19ccee 100644
--- a/packages/vitnode/src/api/modules/users/routes/sign-in.route.ts
+++ b/packages/vitnode/src/api/modules/users/routes/sign-in.route.ts
@@ -2,16 +2,25 @@ import { z } from "zod";
import { assertPasswordSignInEnabled } from "@/api/lib/password-sign-in";
import { buildRoute } from "@/api/lib/route";
+import {
+ EMAIL_NOT_VERIFIED,
+ mustVerifyEmail,
+} from "@/api/models/email-verification";
import { SessionModel } from "@/api/models/session";
import { SessionAdminModel } from "@/api/models/session-admin";
import { UserModel } from "@/api/models/user";
import { CONFIG_PLUGIN } from "@/config";
+import {
+ USER_EMAIL_MAX_LENGTH,
+ USER_PASSWORD_MAX_LENGTH,
+} from "../credential-limits";
+
export const zodSignInSchema = z.object({
- email: z.email().toLowerCase().openapi({
+ email: z.email().max(USER_EMAIL_MAX_LENGTH).toLowerCase().openapi({
example: "test@test.com",
}),
- password: z.string().openapi({
+ password: z.string().max(USER_PASSWORD_MAX_LENGTH).openapi({
example: "Test123!",
}),
isAdmin: z.boolean().optional().openapi({
@@ -37,14 +46,19 @@ export const signInRoute = buildRoute({
},
responses: {
403: {
- description: "Access Denied",
+ content: {
+ "application/json": {
+ schema: z.object({ error: z.literal(EMAIL_NOT_VERIFIED) }),
+ },
+ },
+ description:
+ "Access Denied. Wrong credentials answer with no body; a correct password for an account whose email is not confirmed yet answers `{ error: 'email_not_verified' }`.",
},
201: {
content: {
"application/json": {
schema: z.object({
id: z.number(),
- token: z.string(),
}),
},
},
@@ -62,14 +76,17 @@ export const signInRoute = buildRoute({
});
if (isAdmin) {
- const { token } = await new SessionAdminModel(c).createSessionByUserId(
- data.id,
- );
+ await new SessionAdminModel(c).createSessionByUserId(data.id);
+
+ return c.json({ id: data.id }, 201);
+ }
- return c.json({ id: data.id, token }, 201);
+ if (mustVerifyEmail(c, data)) {
+ return c.json({ error: EMAIL_NOT_VERIFIED } as const, 403);
}
- const { token } = await new SessionModel(c).createSessionByUserId(data.id);
- return c.json({ id: data.id, token }, 201);
+ await new SessionModel(c).createSessionByUserId(data.id);
+
+ return c.json({ id: data.id }, 201);
},
});
diff --git a/packages/vitnode/src/api/modules/users/routes/sign-up.route.ts b/packages/vitnode/src/api/modules/users/routes/sign-up.route.ts
index 63363240c9..051740dd8a 100644
--- a/packages/vitnode/src/api/modules/users/routes/sign-up.route.ts
+++ b/packages/vitnode/src/api/modules/users/routes/sign-up.route.ts
@@ -1,27 +1,35 @@
import { z } from "zod";
+import { describeError } from "@/api/lib/error-details";
import { assertPasswordSignInEnabled } from "@/api/lib/password-sign-in";
import { buildRoute } from "@/api/lib/route";
+import { EmailVerificationModel } from "@/api/models/email-verification";
import { PasswordModel } from "@/api/models/password";
import { UserModel } from "@/api/models/user";
import { CONFIG_PLUGIN } from "@/config";
import { SessionModel } from "../../../models/session";
+import {
+ USER_EMAIL_MAX_LENGTH,
+ USER_NAME_MAX_LENGTH,
+ USER_PASSWORD_MAX_LENGTH,
+} from "../credential-limits";
const nameRegex = /^(?!.* {2})[\p{L}\p{N}._@ -]*$/u;
export const zodSignUpSchema = z.object({
- email: z.email().toLowerCase().openapi({
+ email: z.email().max(USER_EMAIL_MAX_LENGTH).toLowerCase().openapi({
example: "test@test.com",
}),
name: z
.string()
.openapi({ example: "test" })
.min(3)
+ .max(USER_NAME_MAX_LENGTH)
.refine(val => nameRegex.test(val), {
message: "Invalid name",
}),
- password: z.string().min(8).openapi({
+ password: z.string().min(8).max(USER_PASSWORD_MAX_LENGTH).openapi({
example: "Test123!",
}),
newsletter: z.boolean().default(false).optional(),
@@ -55,7 +63,8 @@ export const signUpRoute = buildRoute({
}),
},
},
- description: "User created",
+ description:
+ "User created. With `emailVerified: false` no session was started and a confirmation link was emailed instead.",
},
400: {
description: "Bad Request",
@@ -78,7 +87,15 @@ export const signUpRoute = buildRoute({
if (data.emailVerified) {
await new SessionModel(c).createSessionByUserId(data.id);
} else {
- // TODO: Send verification email
+ try {
+ await new EmailVerificationModel(c).send(data);
+ } catch (error) {
+ await c
+ .get("log")
+ .error(
+ `Could not send the email confirmation link to user ${data.id}: ${describeError(error)}`,
+ );
+ }
}
return c.json(
diff --git a/packages/vitnode/src/api/modules/users/routes/verify-email.route.ts b/packages/vitnode/src/api/modules/users/routes/verify-email.route.ts
new file mode 100644
index 0000000000..199845f0da
--- /dev/null
+++ b/packages/vitnode/src/api/modules/users/routes/verify-email.route.ts
@@ -0,0 +1,57 @@
+import { z } from "zod";
+
+import { buildRoute } from "@/api/lib/route";
+import { EmailVerificationModel } from "@/api/models/email-verification";
+import { CONFIG_PLUGIN } from "@/config";
+
+export const zodVerifyEmailSchema = z.object({
+ token: z
+ .string()
+ .min(16)
+ .max(512)
+ .regex(/^[A-Za-z0-9_-]+$/)
+ .openapi({ example: "abcdefg12345abcdefg12345" }),
+});
+
+export const verifyEmailRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ route: {
+ method: "post",
+ description: "Confirm an email address with the link sent at sign-up",
+ path: "/verify-email",
+ request: {
+ body: {
+ required: true,
+ content: {
+ "application/json": {
+ schema: zodVerifyEmailSchema,
+ },
+ },
+ },
+ },
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({ email: z.string() }),
+ },
+ },
+ description: "Email confirmed - the member can sign in now",
+ },
+ 400: {
+ description: "The link is wrong, already used or expired",
+ },
+ },
+ },
+ handler: async c => {
+ const { token } = c.req.valid("json");
+
+ const user = await new EmailVerificationModel(c).verify(token);
+
+ if (!user) {
+ return c.text("Invalid or expired token", 400);
+ }
+
+ return c.json({ email: user.email }, 200);
+ },
+});
diff --git a/packages/vitnode/src/api/modules/users/sso/routes/callback.route.ts b/packages/vitnode/src/api/modules/users/sso/routes/callback.route.ts
index 12b0e5cd7f..f5205f11e7 100644
--- a/packages/vitnode/src/api/modules/users/sso/routes/callback.route.ts
+++ b/packages/vitnode/src/api/modules/users/sso/routes/callback.route.ts
@@ -26,7 +26,6 @@ export const callbackRoute = buildRoute({
"application/json": {
schema: z.object({
id: z.number(),
- token: z.string(),
}),
},
},
@@ -63,10 +62,8 @@ export const callbackRoute = buildRoute({
);
}
- const { token } = await new SessionModel(c).createSessionByUserId(
- outcome.userId,
- );
+ await new SessionModel(c).createSessionByUserId(outcome.userId);
- return c.json({ id: outcome.userId, token }, 200);
+ return c.json({ id: outcome.userId }, 200);
},
});
diff --git a/packages/vitnode/src/api/modules/users/sso/routes/callback.test.ts b/packages/vitnode/src/api/modules/users/sso/routes/callback.test.ts
index 7fabfdc633..be40138b14 100644
--- a/packages/vitnode/src/api/modules/users/sso/routes/callback.test.ts
+++ b/packages/vitnode/src/api/modules/users/sso/routes/callback.test.ts
@@ -133,7 +133,7 @@ describe("signing in through an SSO provider", () => {
const response = await h.signInThroughGoogle();
expect(response.status).toBe(200);
- expect(await response.json()).toEqual({ id: 7, token: "session-token" });
+ expect(await response.json()).toEqual({ id: 7 });
expect(h.createSession).toHaveBeenCalledWith(7);
expect(h.createAdminSession).not.toHaveBeenCalled();
expect(response.headers.getSetCookie().join(";")).not.toContain(
diff --git a/packages/vitnode/src/api/modules/users/sso/routes/link.route.ts b/packages/vitnode/src/api/modules/users/sso/routes/link.route.ts
index 3249b3b9a4..24e3a2d5b6 100644
--- a/packages/vitnode/src/api/modules/users/sso/routes/link.route.ts
+++ b/packages/vitnode/src/api/modules/users/sso/routes/link.route.ts
@@ -6,8 +6,14 @@ import { SessionModel } from "@/api/models/session";
import { SSOModel } from "@/api/models/sso";
import { CONFIG_PLUGIN } from "@/config";
+import { USER_PASSWORD_MAX_LENGTH } from "../../credential-limits";
+
export const zodSsoLinkSchema = z.object({
- password: z.string().min(1).max(1024).openapi({ example: "Test123!" }),
+ password: z
+ .string()
+ .min(1)
+ .max(USER_PASSWORD_MAX_LENGTH)
+ .openapi({ example: "Test123!" }),
token: z.string().min(16).max(2048),
});
@@ -37,7 +43,6 @@ export const linkRoute = buildRoute({
"application/json": {
schema: z.object({
id: z.number(),
- token: z.string(),
}),
},
},
@@ -63,8 +68,8 @@ export const linkRoute = buildRoute({
providerId,
token,
});
- const session = await new SessionModel(c).createSessionByUserId(userId);
+ await new SessionModel(c).createSessionByUserId(userId);
- return c.json({ id: userId, token: session.token }, 201);
+ return c.json({ id: userId }, 201);
},
});
diff --git a/packages/vitnode/src/api/modules/users/users.module.ts b/packages/vitnode/src/api/modules/users/users.module.ts
index a61576b3f4..b4d646263c 100644
--- a/packages/vitnode/src/api/modules/users/users.module.ts
+++ b/packages/vitnode/src/api/modules/users/users.module.ts
@@ -9,6 +9,7 @@ import { listDevicesRoute } from "./routes/devices.route";
import { mePolicyRoute } from "./routes/me-policy.route";
import { permissionsRoute } from "./routes/permissions.route";
import { profileRoute } from "./routes/profile.route";
+import { resendVerificationRoute } from "./routes/resend-verification.route";
import { resetPasswordRoute } from "./routes/reset-passowrd.route";
import { revokeDeviceRoute } from "./routes/revoke-device.route";
import { sessionRoute } from "./routes/session.route";
@@ -17,6 +18,7 @@ import { signOutRoute } from "./routes/sign-out.route";
import { signUpRoute } from "./routes/sign-up.route";
import { updateMyTimeZoneRoute } from "./routes/time-zone.route";
import { updateMeRoute } from "./routes/update-me.route";
+import { verifyEmailRoute } from "./routes/verify-email.route";
import { ssoUserModule } from "./sso/sso.module";
export const usersModule = buildModule({
@@ -28,6 +30,8 @@ export const usersModule = buildModule({
signOutRoute,
signUpRoute,
resetPasswordRoute,
+ verifyEmailRoute,
+ resendVerificationRoute,
changePasswordRoute,
permissionsRoute,
listDevicesRoute,
diff --git a/packages/vitnode/src/api/plugin.ts b/packages/vitnode/src/api/plugin.ts
index baa051b764..8a68cd9cc2 100644
--- a/packages/vitnode/src/api/plugin.ts
+++ b/packages/vitnode/src/api/plugin.ts
@@ -83,6 +83,7 @@ export const newBuildPluginApiCore = buildApiPlugin({
{ permission: "can_send_test_email", dependsOn: ["can_view"] },
{ permission: "can_test_storage", dependsOn: ["can_view"] },
{ permission: "can_test_ai", dependsOn: ["can_view"] },
+ { permission: "can_manage_search", dependsOn: ["can_view"] },
],
files: [
"can_view",
diff --git a/packages/vitnode/src/blocks/built-in/cta.tsx b/packages/vitnode/src/blocks/built-in/cta.tsx
index 2a4b3ad7e2..fefa178693 100644
--- a/packages/vitnode/src/blocks/built-in/cta.tsx
+++ b/packages/vitnode/src/blocks/built-in/cta.tsx
@@ -3,6 +3,7 @@ import { MegaphoneIcon } from "lucide-react";
import type { BlockComponentProps, BlockData } from "../types";
import { field } from "../../content/fields";
+import { isValidNavigationHref } from "../../lib/navigation";
import { defineBlock } from "../define";
const ctaFields = {
@@ -27,12 +28,14 @@ const Cta = ({ data }: BlockComponentProps) => (
) : null}
-
- {data.label}
-
+ {isValidNavigationHref(data.href) ? (
+
+ {data.label}
+
+ ) : null}
);
diff --git a/packages/vitnode/src/blocks/built-in/hero.tsx b/packages/vitnode/src/blocks/built-in/hero.tsx
index 7197c041c6..83bf7931f8 100644
--- a/packages/vitnode/src/blocks/built-in/hero.tsx
+++ b/packages/vitnode/src/blocks/built-in/hero.tsx
@@ -3,6 +3,7 @@ import { PanelTopIcon } from "lucide-react";
import type { BlockComponentProps, BlockData } from "../types";
import { field } from "../../content/fields";
+import { isValidNavigationHref } from "../../lib/navigation";
import { defineBlock } from "../define";
const heroFields = {
@@ -42,7 +43,9 @@ const Hero = ({ data }: BlockComponentProps) => {
) : null}
- {data.linkHref && data.linkLabel ? (
+ {data.linkHref &&
+ data.linkLabel &&
+ isValidNavigationHref(data.linkHref) ? (
+ render(
+ ,
+ );
+
+const renderHero = (linkHref: string) =>
+ render(
+ ,
+ );
+
+const UNSAFE_HREFS = [
+ "javascript:alert(document.cookie)",
+ " javascript:alert(1)",
+ "data:text/html,",
+ "//evil.example",
+ "http://plain.example",
+];
+
+describe("built-in block links", () => {
+ it.each(["/discover", "https://vitnode.com/docs"])(
+ "renders %s as a link",
+ href => {
+ renderCta(href);
+ renderHero(href);
+
+ expect(
+ screen.getByRole("link", { name: "Join us" }).getAttribute("href"),
+ ).toBe(href);
+ expect(
+ screen.getByRole("link", { name: "Read more" }).getAttribute("href"),
+ ).toBe(href);
+ },
+ );
+
+ it.each(UNSAFE_HREFS)("drops the call-to-action link for %s", href => {
+ renderCta(href);
+
+ expect(screen.getByRole("heading", { name: "Hi" })).toBeTruthy();
+ expect(screen.queryByRole("link")).toBeNull();
+ });
+
+ it.each(UNSAFE_HREFS)("drops the hero link for %s", href => {
+ renderHero(href);
+
+ expect(screen.getByRole("heading", { name: "Welcome" })).toBeTruthy();
+ expect(screen.queryByRole("link")).toBeNull();
+ });
+});
diff --git a/packages/vitnode/src/content/server/editorial-service.ts b/packages/vitnode/src/content/server/editorial-service.ts
index 4b44df485f..e3c5745e8e 100644
--- a/packages/vitnode/src/content/server/editorial-service.ts
+++ b/packages/vitnode/src/content/server/editorial-service.ts
@@ -53,6 +53,7 @@ import {
} from "./delivery-writes";
import {
assertContentFileReferences,
+ contentFileCollectionFields,
ContentFileReferenceError,
contentSnapshotFileIds,
} from "./files";
@@ -394,6 +395,10 @@ export const createContentEditorialService = <
const toRow = (row: Record): ContentSelect =>
projectRow(row) as ContentSelect;
+ const galleryFieldNames = Object.keys(
+ contentFileCollectionFields(definition),
+ );
+
const versionOf = (row: Record): number =>
typeof row.version === "number" ? row.version : 1;
@@ -994,7 +999,9 @@ export const createContentEditorialService = <
// satisfies a `maxBytes` somebody has since lowered is a different
// question, and the answer is the same 422 a dropped column gets.
try {
- await assertContentFileReferences(c, definition, patch, tx);
+ await assertContentFileReferences(c, definition, patch, tx, {
+ existing: async () => Promise.resolve(patch),
+ });
} catch (error) {
if (!(error instanceof ContentFileReferenceError)) throw error;
@@ -1134,7 +1141,12 @@ export const createContentEditorialService = <
};
}
- await assertContentFileReferences(c, definition, patch, tx);
+ await assertContentFileReferences(c, definition, patch, tx, {
+ existing: async () => ({
+ ...projectRow(current),
+ ...(await store?.load(id, tx, galleryFieldNames)),
+ }),
+ });
// The version guard runs **first**, before a single junction or child
// row is touched. That ordering is the whole concurrency story: a writer
diff --git a/packages/vitnode/src/content/server/files.test.ts b/packages/vitnode/src/content/server/files.test.ts
index 7b43ee2c7a..5b3c070c40 100644
--- a/packages/vitnode/src/content/server/files.test.ts
+++ b/packages/vitnode/src/content/server/files.test.ts
@@ -30,6 +30,7 @@ const fileRow = (
mimeType: null | string;
name: string;
size: number;
+ userId: null | number;
}> = {},
) => ({
id,
@@ -50,11 +51,15 @@ const fileRow = (
*/
const makeCtx = (
rows: ReturnType[],
- { hasAdapter = true }: { hasAdapter?: boolean } = {},
+ {
+ adminId,
+ hasAdapter = true,
+ }: { adminId?: number; hasAdapter?: boolean } = {},
) => {
const where = vi.fn().mockResolvedValue(rows);
const select = vi.fn(() => ({ from: vi.fn(() => ({ where })) }));
const store: Record = {
+ admin: adminId === undefined ? undefined : { user: { id: adminId } },
core: {
storage: hasAdapter
? { adapter: { delete: vi.fn(), getUrl: vi.fn(), upload: vi.fn() } }
@@ -243,6 +248,116 @@ describe("resolveContentPublicRowFiles", () => {
});
});
+describe("assertContentFileReferences, for a signed-in editor", () => {
+ const EDITOR_ID = 3;
+ const OTHER_USER_ID = 8;
+
+ const check = async (
+ values: Record,
+ rows: ReturnType[],
+ existing?: Record,
+ ) => {
+ const { ctx } = makeCtx(rows, { adminId: EDITOR_ID });
+
+ return await assertContentFileReferences(
+ ctx,
+ testFilePostContentType,
+ values,
+ undefined,
+ existing ? { existing: async () => Promise.resolve(existing) } : {},
+ )
+ .then(() => null)
+ .catch((error: unknown) => error as ContentFileReferenceError);
+ };
+
+ it("accepts a file uploaded for this content type by anybody", async () => {
+ expect(
+ await check({ cover: 1 }, [
+ fileRow(1, {
+ metadata: { contentTypeId: testFilePostContentType.id },
+ userId: OTHER_USER_ID,
+ }),
+ ]),
+ ).toBeNull();
+ });
+
+ it("accepts a file the editor uploaded themselves", async () => {
+ expect(
+ await check({ cover: 1 }, [fileRow(1, { userId: EDITOR_ID })]),
+ ).toBeNull();
+ });
+
+ it.each([
+ ["another user's file", { userId: OTHER_USER_ID }],
+ [
+ "a file uploaded for another content type",
+ { metadata: { contentTypeId: "other.type" }, userId: OTHER_USER_ID },
+ ],
+ ["a file nobody owns", { userId: null }],
+ ])("refuses %s exactly like a missing one", async (_label, overrides) => {
+ const error = await check({ cover: 1 }, [fileRow(1, overrides)]);
+
+ expect(error?.code).toBe("CONTENT_FILE_NOT_FOUND");
+ expect(error?.field).toBe("cover");
+ expect(error?.detail).toBe(
+ 'File 1 does not exist, so "cover" cannot point at it.',
+ );
+ });
+
+ it("keeps a foreign file the record already holds", async () => {
+ expect(
+ await check(
+ { cover: 1, title: "Renamed" },
+ [fileRow(1, { userId: OTHER_USER_ID })],
+ { cover: 1 },
+ ),
+ ).toBeNull();
+ });
+
+ it("refuses a foreign file the record does not hold", async () => {
+ const error = await check(
+ { cover: 2 },
+ [fileRow(2, { userId: OTHER_USER_ID })],
+ { cover: 1 },
+ );
+
+ expect(error?.code).toBe("CONTENT_FILE_NOT_FOUND");
+ });
+
+ it("still applies the field's rules to a file the record holds", async () => {
+ const error = await check(
+ { animation: 5 },
+ [
+ fileRow(5, {
+ mimeType: "application/pdf",
+ name: "spec.pdf",
+ userId: OTHER_USER_ID,
+ }),
+ ],
+ { animation: 5 },
+ );
+
+ expect(error?.code).toBe("CONTENT_FILE_MIME_TYPE_NOT_ALLOWED");
+ });
+
+ it("does not read the record when every file is the editor's to use", async () => {
+ const existing = vi.fn();
+ const { ctx } = makeCtx([fileRow(1, { userId: EDITOR_ID })], {
+ adminId: EDITOR_ID,
+ });
+
+ await assertContentFileReferences(
+ ctx,
+ testFilePostContentType,
+ { cover: 1 },
+ undefined,
+ { existing },
+ );
+
+ expect(existing).not.toHaveBeenCalled();
+ });
+});
+
describe("assertContentFileReferences", () => {
const ok = async (
values: Record,
diff --git a/packages/vitnode/src/content/server/files.ts b/packages/vitnode/src/content/server/files.ts
index 64119b77f3..ae06fee6cb 100644
--- a/packages/vitnode/src/content/server/files.ts
+++ b/packages/vitnode/src/content/server/files.ts
@@ -18,6 +18,7 @@ import { CONTENT_FILE_CODES } from "../const";
import { ContentInputError } from "../errors";
import { contentFileConstraints, validateContentFile } from "../files";
import { partitionContentFields } from "../localization";
+import { resolveContentActor } from "./actor";
export const contentFileFields = (
definition: AnyContentTypeDefinition,
@@ -71,6 +72,10 @@ interface ContentFileRow {
size: number;
}
+interface ContentFileOriginRow extends ContentFileRow {
+ userId?: null | number;
+}
+
const toDescriptor = (
row: ContentFileRow,
url: (key: string) => string,
@@ -92,28 +97,38 @@ const toDescriptor = (
};
};
-export const resolveContentFileDescriptors = async (
+const readContentFileRows = async (
c: Context,
ids: readonly number[],
tx?: ContentDatabase,
-): Promise