Security Documentation
Security reference map and the internal self-assessment. The August 2026 checklist re-checks to ~99.9/100 (99.93). A third-party penetration test is still open.
On this page
This page is the map of the security reference and the internal self-assessment of the controls in the current source. The weighted score was checked against that source on 2026-09-29. Inventory rows added on 2026-10-11 describe controls that shipped after that check. They were not given a new weight, so the score below stays 99.93.
The ~99.9% figure below is this repository’s own weighted checklist. It is not a penetration test. One item stays on the open list: a dated penetration test by someone outside the project. Still open →
Self-assessment
The self-assessment has two parts, both dated 2026-09-29: the weighted checklist, and an inventory of what the source does today. An inventory row leaves the open list when the outstanding work is in the tree and a regression test fails if that control is removed. The penetration-test row leaves the list when a dated third-party report exists. That report is not one of the six weights, so a clean scanner run cannot turn the checklist into an external test.
Internal scorecard
Same weights as the August 2026 checklist. Re-checked on 2026-09-29. bun run test:security passed 19 files / 343 tests. bun run risk:audit passed 5/5: the security-risk scan reported 0 findings over 2,846 files, the secret-misuse scan and the slop scanner reported 0 errors, bun audit reported no vulnerabilities in 718 packages, and OSV.dev reported 0 affected of 764 packages.
| Dimension | Weight | Score | This check |
|---|---|---|---|
| CVE track record | 25% | 100 | No published advisory in this run’s bun audit or OSV.dev query. |
| Cryptography | 15% | 100 | AES-256-GCM, Argon2id, HMAC-SHA-256. The algorithms were not re-derived by the scanners; they are the ones in src/utils/security/crypto.ts. |
| Disclosure & response | 10% | 100 | security.txt, the reply windows in SECURITY.md, and GitHub Security Advisories. This run did not send a test report to the inbox. |
| Auth & session | 20% | 99.8 | User-agent step-up, WebAuthn origin and user-presence checks, and the SSO metadata cache are in the tree with regression tests. Back-channel logout still walks only this process’s sessions. The 0.2 is the residual the August row already carried. This re-check does not invent a higher score. |
| Input validation | 15% | 99.8 | private and hidden fields are redacted on the way out of a read. A warm collection GET runs wafGuard before cache or database. The 0.2 stays for the same reason. |
| Dependency hygiene | 15% | 100 | bun audit and OSV.dev, as cited above. |
Weighted: 25.0 + 15.0 + 10.0 + 19.96 + 14.97 + 15.00 = 99.93/100, which rounds to ~99.9%.
The August footnotes that are now in the tree (private-field reads, the warm-read threat scan, user-agent step-up, WebAuthn client-data verification, the SSO metadata cache) did not move the arithmetic. The rubric never assigned them a point value of their own, and the two 99.8 rows were not raised.
How close this is to a penetration test
A penetration test is a dated report by someone outside the project who attacks a running deployment. This repository does not contain that report. The in-repo checks fail when a named control is removed: 343 security regressions, plus the five scanners in bun run risk:audit. They do not search for a bug the checklist never named. The OWASP matrix maps controls to chapters. It is not a passed ASVS audit and not a certificate. The remaining distance is the external engagement itself, which is the open row below and the roadmap item “Third-Party Security Audit”.
Still open
| Item | In the current tree | Still outstanding |
|---|---|---|
| Third-party penetration test | Advisory-database scans and the in-repo scanners (bun run risk:audit, bun run test:security) cover the cases they contain. |
A dated penetration test by someone outside the project. |
In the current tree
| Area | What the source does | Where |
|---|---|---|
| Authorization | Unmapped /api routes fail closed. ENDPOINT_PERMISSIONS plus hasPermissionWithRoles is the authorization point. The unwired Policy-as-Code engine is gone. |
src/routes/api/[...path]/+server.ts, WAF and middleware |
| Request lanes | Writes run handleSecurity, rate limit, CSRF, authentication, and authorization. API reads run handleSecurity, authentication, and authorization, and skip the mutation rate limiter. Page routes run the full sequence. Page loads then use getAuthenticatedUser and requirePagePermission. |
src/hooks.server.ts |
| Field access | permissions.visibility of private or hidden is a guard even when the role lists are empty. Non-admins are redacted on SDK reads. Writes of a guarded field return 403. The response cache keeps the full row. Filter and sort drop a guarded name the caller may not read. Admins, system callers, and schemas with no guarded fields keep the same query object. A keyset cursor on a blocked field returns 400. |
Field-level access control, src/utils/guarded-query.ts |
| Row ownership | When a collection sets ownership: { field }, list, count, and stream stay scoped to that field. Update, delete, and bulk delete return 404 both for a missing row and for a row the caller does not own. A caller with no user id matches nothing. Admins and system writes skip the extra read. A schema with no ownership adds no query. |
src/services/sdk/namespaces/collections-namespace.ts, Schema model |
| Scheduled publish | A status-transition job stores the authenticated user. The client cannot set the actor or the tenant. The worker and the schedule sweep run the publication gate as that user. A job or scheduled entry with no actor is left unpublished. | src/services/scheduler.ts, src/services/background/jobs/scheduled-jobs.ts |
| CSV export | A text cell that starts with =, +, -, @, tab, or carriage return is prefixed with ' on the way out. Numbers and booleans are unchanged. |
src/utils/export-encode.ts, Collection export |
| Media list bounds | Media list limit uses the shared page-size ceiling (200). Sort accepts size, filename, updatedAt, createdAt, and mimeType. Anything else stays updatedAt. There is no size index. |
src/routes/api/[...path]/handlers/media.ts |
| WebSocket origin | An upgrade whose Origin host differs from the request host is rejected. An empty Origin is allowed. A malformed Origin is rejected. The check runs before the test-secret path. |
src/hooks.ws.ts, API security |
| Webhook audit | Creating, updating, or deleting a webhook writes WEBHOOK_CREATED, WEBHOOK_UPDATED, or WEBHOOK_DELETED. A failed audit does not fail the request. GET and the test send are not audited. |
src/routes/api/[...path]/handlers/system.ts |
| Passwords and sessions | Argon2id at 64 MiB, time cost 3, parallelism 4. The 1 MiB / time cost 1 parameters apply when NODE_ENV=test, VITEST=true, or BUN_TEST=true. Sessions rotate every 15 minutes. SESSION_TTL_HOURS defaults to 24. Idle timeout follows SESSION_IDLE_HOURS (0 disables it). SESSION_MAX_PER_USER of 0 means no cap. |
Authentication, src/utils/security/crypto.ts |
| Session cookie | httpOnly. On a secure context the name is __Host-auth_sessions, Secure, and SameSite=Strict. Otherwise the name is auth_sessions and SameSite=Lax. |
src/databases/auth/constants.ts |
| Session cache | The turbo auth cache TTL is 60 seconds and slides on every hit (15 minutes when BENCHMARK=true). Credential fields are stripped before a user enters the session cache. |
src/hooks/handle-turbo-get.ts, src/databases/auth/session-user.ts |
| Session risk | A user-agent change on a live session steps mutations up (403 SESSION_RISK_STEP_UP on /api, redirect to /login?reason=step-up on pages). GET, HEAD, and OPTIONS stay available. /api/user/sessions/reauth, /api/auth/login, /api/auth/logout, and /login stay reachable. A successful password reauth adopts the current browser in memory, on the turbo context, and in the session cache. An IP-only change is logged once per session per hour. Empty stored values stay allowed. The collection write lane applies the same step-up before persist. |
src/databases/auth/session-user.ts, src/hooks/handle-authorization.ts, src/hooks/handle-collection-write-lane.ts |
| Passkeys | Register and sign-in compare the ceremony origin with the request origin and require the authenticator user-presence flag. A foreign origin is rejected before the signature is accepted. The assertion signature covers the raw client data bytes. Register, sign-in, and revoke are in the tree. | src/databases/auth/webauthn/webauthn-service.ts, src/routes/login/auth.remote.ts |
| SSO session metadata | OIDC login writes provider metadata to process memory and to the session cache (sso:session:{id}, 7 days) so another node can finish RP-initiated logout. Back-channel logout still walks the sessions this process has in memory. |
src/databases/auth/sso-session.ts |
| Lockout | Five failed logins lock the account for 15 minutes. PASSWORD_MIN_LENGTH defaults to 8. |
src/databases/auth/index.ts |
| CSRF | Mutations on the API dispatcher, GraphQL, and the collection write lane call validateCsrfForRequest. A same-origin Origin or Referer matches the host and skips the token. API-key and API-token users, the Stripe webhook, and public plugin routes skip the dispatcher check. |
src/utils/security/csrf-utils.ts |
| Rate limits | Tier multipliers are admin 10, staff 2, guest 2, anonymous 1. Pressure scaling leaves the admin tier at 1.0. | src/utils/rate-limit/adaptive.ts |
| Heap shedding | handleSecurity rejects non-GET mutations with 503 when the heap ratio exceeds 0.95. /api/system and /setup stay available. Test mode skips the shed. |
src/hooks/handle-security.ts |
| WAF | handleSecurity, the collection write lane, and the collection read lane call wafGuard.inspectEvent on the path, query, and headers. The read lane does this before any cache or database read. Heap shedding and the Redis WAF limiter stay on handleSecurity. The scanner is a linear walk in threat-scan.ts. Honeypot prefixes (/wp-, /xmlrpc.php, /.env, /.git/, /adminer.php, /phpinfo.php, /actuator/) return an empty 200. Known scanner and AI-crawler user agents from a non-local client get the same empty 200. Localhost is exempt from that user-agent response. |
WAF and middleware, Server hooks |
| GraphQL | The route rejects depth above 8 and more than 15 aliases. Introspection is blocked when NODE_ENV=production, DEMO_MODE=true, or BLOCK_GRAPHQL_INTROSPECTION=true. handleSecurity also applies a brace-depth pre-filter at 12. |
src/routes/api/graphql/+server.ts |
| Headers | Pages send Cross-Origin-Embedder-Policy: require-corp. API routes send require-corp, except /api/media/ and paths containing /mediagallery, which send credentialless. The non-production GraphQL playground (ALLOW_GRAPHQL_PLAYGROUND=false turns it off) sends unsafe-none and a CSP that allows the playground scripts. Other API responses use script-src 'self'. |
Content Security Policy, src/hooks/handle-security-headers.ts |
| Identifiers | Primary keys are RFC 9562 UUIDv7. | src/databases/core/id-contract.ts |
| Encryption | AES-256-GCM for field envelopes and the shared crypto module. | Field-level encryption, Cryptography |
| Audit chain | AUDIT_CHAIN_SYNC=true runs the hash chain on the write. When the environment variable is unset, the database setting applies, and the fallback is asynchronous. |
src/utils/security/audit-flags.ts |
| Testing API | NODE_ENV=production returns 403. Other environments need TEST_MODE, VITE_TEST_MODE, or PLAYWRIGHT_TEST, plus a timing-safe x-test-secret. A missing or wrong secret returns 401. BENCHMARK does not open the route. Production builds strip handlers/testing and handle-test-isolation. |
src/utils/test-bypass.server.ts, vite.config.ts |
| Body limit | A declared Content-Length above 15 MiB is rejected with 413 before the body is read. |
src/utils/api-body-limits.ts |
| Disclosure | Reports go to security@sveltycms.com or a GitHub Security Advisory. SECURITY.md states a reply within 48 hours and a critical fix within 7 days (high 30 days, medium and low 90 days). | Incident response |
How a request is checked
classifyRequest in src/hooks.server.ts picks the pipeline after setup is complete.
- Collection read lane. A simple collection
GETorHEADwith a warm session can be answered bytryCollectionReadLanebefore the pipeline. The lane scans the path, query, and headers withwafGuardbefore any cache or database read. Heap shedding and the Redis WAF limiter stay onhandleSecurity. - Collection write lane. Opt-in with
SVELTY_FAST_LANE_WRITE=1. It runs the WAF guard, CSRF, and the session-risk step-up, then one persist. A cold session falls through. - API write pipeline.
handleSecurity, rate limit, system state, compression, authentication, authorization, audit logging, API dispatch. - API read pipeline.
handleSecurity, system state, turbo GET, compression, authentication, authorization, API dispatch, token resolution. Rate limit and audit logging are omitted because those hooks cover mutations. - Page pipeline. The read pipeline plus the mutation rate limiter, redirects, user preferences, and audit logging.
Security headers are applied to responses, including errors. Fast static paths such as /favicon.ico return before the pipeline.
Documentation map
Every page in this folder:
Standards and operations
- OWASP ASVS v4.0.3 compliance matrix
- Tested security features
- Incident response
- Secrets and tokens inventory
- License gate inventory
- WAF guard and security middleware
Authentication and access
- Authentication and authorization
- Login security
- Login resilience
- Rate limiting — token bucket, Redis fallback, adaptive capacity
- Multi-tenant security
- Field-level access control
- Access management — roles, permissions, and website tokens
- Authentication API — 2FA, registration, and profile
- Token API
Cryptography and content
- Cryptography module
- Field-level encryption at rest
- Quantum security — current algorithms and the post-quantum roadmap
- Widget security
- Rich text security
- Content Security Policy and isolation headers
- API security and token hardening
- Security plugin — build-time protection of private settings
- Server hooks
- Cloud storage
- Database resilience
Checks you can run
These commands exercise the tree. The pass counts in the scorecard are from the 2026-09-29 run on this tree.
| Command | What it runs |
|---|---|
bun run risk:audit |
Secret misuse, slop, security-risk scan, bun audit, OSV.dev |
bun run test:security |
Hooks, authorization, media, GraphQL, the field guard, the read-lane threat scan, write-lane step-up, session risk, the WebAuthn ceremony, and the SSO metadata cache |
Deployment checklist
The application ships the controls in the table above. These items are set in the environment that runs it.
-
JWT_SECRET_KEYandENCRYPTION_KEYare high-entropy values generated at setup (ENCRYPTION_KEYis a 32-byte AES-256-GCM key). - The site is served over TLS. HSTS is set when the request is HTTPS.
- The database listens on a private network.
- The setup-wizard admin password has been replaced.
- API tokens use the smallest permission set that caller needs.
- Backup encryption, key rotation, and log retention match the operator’s compliance window.
AUDIT_CHAIN_SYNCis the switch for synchronous hash chaining.
Related
Last updated: 2026-09-29