SECURITY.md
Rama main · réplica de lectura del repositorio original.
# Security Policy
## Supported Versions
| Version | Supported |
|---------|-----------|
| main branch | ✅ |
## Reporting a Vulnerability
**Do NOT open a public issue for security vulnerabilities.**
Instead, please report them responsibly:
1. Email: [security contact via GitHub private vulnerability reporting]
2. Or use GitHub's [private vulnerability reporting](https://github.com/schorsch888/novelworld/security/advisories/new)
Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
We will acknowledge receipt within 48 hours and provide a timeline for resolution.
## Security Measures
### Authentication
- Passwords hashed with bcrypt (cost factor 12) in a bounded blocking pool
- JWT tokens with configurable expiry (default 1 hour)
- Refresh tokens are atomically consumed and rotated in server-side storage
- 401 responses do not leak user existence information
### Data Protection
- All SQL queries use parameterized bindings (no string interpolation)
- File upload accepts TXT, EPUB, and PDF with 10 MiB/20 MiB input limits,
a 20 MiB extracted-text ceiling, bounded blocking parsers, and EPUB aggregate
expansion and duplicate-spine checks
- Production Nginx enforces 20 requests/second per client with burst 40; the
Gateway applies its configurable global backstop after authentication on
protected routes (default 500 requests/second)
- Novel imports, chats, bcrypt, and provider calls have process-wide admission;
imports also have a fixed provider-call budget
- Retention and application-layer erasure boundaries are documented in
[docs/DATA_RETENTION.md](./docs/DATA_RETENTION.md)
- Account export uses JWT-derived identity, internal-token-authenticated service
fragments, explicit field allowlists, a two-request concurrency ceiling, and
a 15-minute end-to-end deadline. See
[docs/ACCOUNT_EXPORT.md](./docs/ACCOUNT_EXPORT.md).
- Reader-confirmed series definitions and safe source-template snapshots are
Novel-owned and account-scoped. Optional Laya matching sends bounded metadata
only; it cannot write associations, which require reader confirmation. See
[ADR 0011](./docs/adr/0011-confirmed-series-worlds.md).
Optional community grouping uses explicit default-off contribution consent,
current memberships, exact canonical identities and recipient-only candidates;
it never exposes donor identities, counts or private series content. The
support threshold is not proof of anonymity, accuracy or Sybil resistance.
See [ADR 0012](./docs/adr/0012-opt-in-community-series.md).
### Infrastructure
- All inter-service communication over internal Docker network
- Only Nginx port (80/443) exposed externally in production
- Database credentials auto-generated on first run
- JWT secret auto-generated (256-bit random)
### Secret Rotation
The three deployment secrets have different rotation contracts:
- **JWT_SECRET** — rotating it invalidates every access token (signature
check). Persisted refresh tokens are opaque server-side rows, not JWTs:
they survive a JWT_SECRET rotation, so the rotation procedure wipes them
explicitly (the same two-step contract the restore path applies). All
sessions end; users log in again.
- **INTERNAL_SERVICE_TOKEN** — authenticates service-to-service calls.
Rotating it requires recreating every service together in one compose
operation; a fleet that mixes old and new tokens breaks internal calls.
- **RUNTIME_CONFIG_KEY** — encrypts the provider configuration saved by the
first-run web setup. It is **not rotatable in place**: rotating it makes the
stored configuration undecryptable and the deployment fails hard with no
redo path, so treat it as fixed for a deployment's lifetime and
re-provision instead.
Procedure (operator, on the host):
1. Update .env with the new JWT_SECRET and INTERNAL_SERVICE_TOKEN.
2. docker compose up -d --force-recreate (all services together).
3. Wipe persisted sessions:
docker exec novel-postgres psql -U novel -d novel_world -c "DELETE FROM refresh_tokens"
4. Users log in again.
The e2e secret_rotation_drill.sh verifies this contract: old access and
refresh tokens are rejected, a new login works, and an account export (which
crosses internal-token-authenticated service calls) succeeds after rotation.
### Release Rollback
`infra/docker/release.sh` is the release/rollback state machine for the
compromised-release response. Manifests pin every image to an immutable
`@sha256` digest plus a release version and git SHA; the state dir holds
`current.env` and `previous.env` with a flock-guarded lock. Every new
post-client-gate deployment, including normal restore and healthy rollback,
writes the exact target to `schema-transition.pending` before migration.
Restore and rollback first discard an unmarked staged candidate so it cannot
conflict with that authority. New rollback uses the same promotion protocol as
upgrade. `rollback.pending` is accepted only as a compatibility recovery record
from older tooling, and the next locked operation converges it before doing
anything else.
That marker is the only recovery authority until promotion: a transition that
may have crossed application-semantic migration 0021, 0024, or 0025 rolls the
exact target forward and never starts the older writer or reader. Adoption
requires all three barriers, and upgrade, marked restore, and rollback refuse
to cross any one backwards. On the supported managed Docker path, a database
after 0024 must not run a pre-0024 Agent or Novel release, and a database after
0025 must not run a pre-0025 Agent release, except through a separately approved
compatibility procedure. Experimental desktop archives are forward-migration-only;
an older archive must not reuse a post-0025 data directory because that older
binary cannot enforce these barriers. The marker is storage-synced before migration; promotion is
synced before marker removal, and marker removal is synced again. For an
upgrade, the former current manifest is renamed and synced as `previous`
before the new current manifest is installed. In both adoption and upgrade,
the installed target tempfile is included in that pre-rename sync.
What the release_state_drill.sh proves locally (no registry required):
- Manifest grammar fails closed: non-digest images, malformed git SHAs and
versions, unknown, duplicate, empty, or missing keys are all rejected.
- Every upgrade and rollback guard fires before `deploy_manifest`'s secrets
check (and therefore before any network access): a divergent manifest for
the current SHA, infrastructure-image changes, malformed rollback targets,
a missing or mismatched previous release, and concurrent operations (held
lock) all stop with an actionable error.
- Redis mode verifies that the running container's immutable image reference
exactly matches the active release manifest before replacing any application
service; the state drill rejects a healthy container with a mismatched image.
- A normal restore and a healthy rollback clear an unmarked candidate before
the schema marker is written, durably promote the exact marked target, and
clear the marker only after the promoted pair is synced.
- A legacy interrupted rollback recovers its current/previous pair before the
next command, and a wedged legacy marker with missing files fails closed: the
marker survives and requires explicit operator clearing.
- An interrupted semantic-barrier adoption/upgrade rolls the exact schema-transition
manifest forward, accepts a missing downloaded candidate, rejects a
different candidate, preserves the old current as previous on upgrade, and
clears the marker only after the health-gated manifest is promoted.
What stays gated: the image-level deployment (`deploy_manifest`: git checkout
of the release SHA plus `compose pull` of the digest-pinned images) runs only
with a reachable registry. CI validates the manifest grammar and lock
guards; the deployment path itself is not exercised by a local drill. The
state drill pins sync/rename order and simulates process crashes, but it does
not inject a real Linux host power loss or prove filesystem directory-entry
durability across one.
SBOM generation has since landed (see Dependency Policy). Release-file
provenance is documented in the Release-file provenance section below;
deploy-time SBOM admission and platform-native signing remain gated.
### Dependency Policy
CI runs `cargo audit` against both `Cargo.lock` files with the live RustSec
advisory database and `pnpm audit --prod --audit-level high` against the
frontend's frozen lockfile. A newly reported Rust vulnerability or
HIGH/CRITICAL advisory in a shipped browser dependency fails the build;
development-only frontend tooling is outside that production-dependency gate.
Dependabot covers Cargo, npm, Dockerfiles, Compose files, and GitHub Actions.
The temporary TypeScript 7/6 npm aliases, pinned OS packages in Dockerfile
`RUN` steps, the release workflow's GitHub CLI archive, and immutable scanner
images embedded in shell commands are
verified manually against upstream releases during each dependency-maintenance
change because Dependabot does not parse those forms.
CI runs `bash tools/scan-secrets.sh` over history reachable from the selected
HEAD. A detected credential fails the build; unrelated branch histories are
excluded. This is pattern-based detection, not proof that no secret can exist.
`.gitleaks.toml` is the full default rule set plus narrow allowlists for
the upstream rule-set examples and two deliberate test fixtures (the CI
`RUNTIME_CONFIG_KEY` smoke placeholder and two static provider model names).
Credential-shaped upstream examples are regex-escaped so the allowlist still
matches historical fixtures without committing complete key-shaped literals.
CI and `tests/e2e/gitleaks_self_test.sh` use that same entrypoint with the
digest-pinned Docker image; repository-local scanner executables cannot
replace it. It requires non-shallow, readable HEAD history and a positive,
completed scan without Git
or scanner errors. Missing/partial evidence fails even if the scanner exits
zero. Linked worktrees mount their exact Git metadata read-only. The scanner
has a 300-second deadline without retry; CI also bounds the scan step to six
minutes, including container startup. Local Docker startup is host-managed.
Gitleaks redaction is enabled. Diagnostics are captured privately and removed
on exit; only fixed outcomes and commit counts reach the console. Exit 42
means detected credentials; exit 1 means operational/incomplete evidence.
The self-test rejects generated GitHub and DeepSeek-shaped credentials,
verifies unrelated-ref isolation and linked-worktree scanning, and checks
invalid histories, incomplete results and output privacy. Empty histories
cannot supply a positive scan control. For a local checkout, run
`bash tools/scan-secrets.sh /path/to/checkout`; it scans committed history,
so uncommitted/ignored local secret files are outside that evidence.
`.cargo/audit.toml` currently carries no vulnerability ignores. Informational
warnings for unmaintained or unsound transitive crates remain visible for
upstream tracking without weakening the vulnerability gate.
The remaining root-lock informational warning (`ttf-parser` unmaintained)
stays non-failing because it is transitive through an already-latest upstream
release. The formerly warned `lru` chain is now on patched 0.18.3. Each
acknowledged entry is re-reviewed whenever its dependency chain updates; new
advisories are not silently ignored. JWT signing and verification use the
`aws_lc_rs` backend of jsonwebtoken, so the rsa crate is not part of the tree
at all.
The independent desktop lock has one narrower reviewed exception:
`RUSTSEC-2024-0429` affects `glib::VariantStrIter` in `glib 0.18.5`, which is
present only in the experimental Linux desktop graph through Tauri 2's GTK3
stack. Neither NovelWorld nor any crate in the resolved graph calls its sole
public constructor, `Variant::array_iter_str`; Windows and macOS do not resolve
`glib`. GTK 0.18 requires `glib ^0.18`, so the patched `glib >=0.20` cannot be
selected until Tauri completes its GTK4/WebKitGTK 6 migration. CI audits the
desktop lock with `--deny unsound` and an exact ignore for this advisory, so a
different unsound advisory still fails. Dependabot tracks the nested manifest
weekly, and desktop dependency PRs are excluded from automatic merge. Re-review
this exception on any Tauri/GTK/Wry graph change or direct use of the affected
API, and remove it when the supported upstream stack is fixed.
CI also runs `cargo deny check licenses sources` with `deny.toml`: every
dependency license must be in the explicitly allowed permissive set (a new
dependency with a license outside the set fails the build and forces a
deliberate review), unlicensed crates are denied, and unknown registry/git
sources are denied. Dependency advisories stay owned by cargo-audit to avoid
maintaining two ignore lists.
Every pushed application image is scanned in the tag pipeline (docker.yml)
with the pinned `aquasec/trivy:0.74.0` for HIGH/CRITICAL vulnerabilities
(--ignore-unfixed, vuln scanner); any finding or unresolved vulnerability
detail fails the release. The same check runs locally and in release CI via
`infra/security/scan-images.sh`. The four base
images in the Dockerfiles are digest-pinned. The digest-pinned
infrastructure images are scanned when they are re-pinned through the
separately approved infrastructure procedure. A native scan captured on
2026-10-22 for the current `pgvector/pgvector:pg18@sha256:2ba9ca5f…`
(`POSTGRES_IMAGE`) reported 58 vulnerability rows: 13 CRITICAL and 45 HIGH,
including 32 Debian OS rows and 26 gosu Go rows. This supersedes the earlier
22-row summary. No finding is waived or treated as clean.
The edge Nginx candidate `nginx:alpine@sha256:060849a174666c53439dc7cecbf8f67e225c7cbfb1a44ca97915d9a172a81e6d`
passed the native HIGH/CRITICAL gate on 2026-10-22 with zero rows. Its captured
SBOM contained 73 components, including 72 reconciled packages, and zero
vulnerability records. This scan does not establish runtime compatibility,
deployment use, or qualification.
The 2026-08-31 re-pin moved every Compose image to the newest artifact in its
supported release channel. Prometheus scans clean. The pinned Redis 8.10.1
image reports two HIGH package rows for one OpenSSL QUIC-server issue
(`CVE-2026-14456`); NovelWorld runs ordinary Redis TCP without TLS or QUIC and
does not publish its production port. The pinned Python 3.14.7 drill image
reports two HIGH rows from pip's vendored SBOM (`GHSA-6v7p-g79w-8964` and
`CVE-2025-47273`), but the vulnerable msgpack extension and setuptools
package-index code are absent and the read-only mock imports only the standard
library. Grafana, MinIO, pgvector, and Python drill images still contain
upstream-owned findings. They are not application-release images and are not
reported as clean or silently ignored: each stays
digest-pinned and role-bounded, `docker-compose` Dependabot tracks all of them,
and every new artifact must be re-scanned before re-pinning. The shipped
frontend runtime installs fixed OpenSSL and PCRE2 packages on its pinned Nginx
base; each rebuilt application image must pass the shared gate.
The current release pipeline (docker.yml) generates one CycloneDX 1.7 SBOM per
application image with the pinned trivy release and ships them with the
release artifact, bound to the recorded image digest via the generated
`sboms/digests.txt` sidecar;
`infra/security/generate-sboms.sh` is the local operator form.
For registry releases, successful per-run build and vulnerability-scan digest
records drive both the release manifest and SBOM generation; the pipeline does
not resolve mutable SHA tags again. The local generator keeps its explicit
image-ID fallback. Local commands require GNU `timeout`: pulls are bounded to
10 minutes and scans to 15 minutes, each with an additional 30-second
termination grace; no command is retried automatically. A timeout does not
guarantee that daemon work has fully stopped.
#### Release-file provenance
The release workflow's provenance outcome covers the existing flat release
files: `release.env`, six CycloneDX SBOMs, `digests.txt`, four desktop
archives, and `desktop-SHA256SUMS`. After the required quality, image, manifest,
and desktop builds succeed, the pinned `actions/attest` v4.2.2 action produces
native provenance for these exact file subjects and writes the Sigstore bundle
as `release-attestation.json`. A `workflow_dispatch` exercises signing and
verification without publishing a GitHub Release, even when its selected ref
is a tag. Tag publication requires a tag-push event and remains blocked until
the required checks and native verification of every file pass. The top-level
signing job requires `GITHUB_WORKFLOW_SHA` and `GITHUB_SHA` to resolve to the
same source/workflow commit.
The tag-push job passes every verified file to one native `gh release create`
invocation. The CLI refuses an existing draft or public Release, stages all
uploads in a temporary draft, publishes only after every upload succeeds, and
removes that draft after an upload or publish failure. If cleanup itself fails,
the remaining Release blocks another run; diagnose it and explicitly remove
that exact draft before retrying. The workflow never reconciles or overwrites
an existing Release.
Consumers must obtain the expected source and signer SHA from an independently
reviewed workflow run or operator record, never only from `release.env`. Verify
each consumed file separately with GitHub CLI 2.100.0 or newer, for example:
```bash
expected_sha="$REVIEWED_SOURCE_SHA"
/path/to/gh attestation verify release.env \
--hostname github.com \
--repo Wisdoverse/novelworld \
--signer-workflow Wisdoverse/novelworld/.github/workflows/docker.yml \
--source-digest "$expected_sha" \
--signer-digest "$expected_sha" \
--deny-self-hosted-runners \
--bundle release-attestation.json
```
Run the same command separately for every SBOM, digest sidecar, desktop
archive, and checksum file. For offline verification, obtain trusted roots
separately with `gh attestation trusted-root` and pass the resulting file with
`--custom-trusted-root`; never trust a root downloaded alongside the release.
A valid signature establishes file content and producing workflow identity;
it does not establish release qualification.
Deploy-time SBOM verification remains a separate gate. Platform-native Windows
code signing, Apple signing/notarization, human review, and release
qualification remain outside this file-provenance outcome.
### Provider Incidents
`tests/e2e/provider_outage_drill.sh` verifies the fail-closed behavior when
the LLM provider disappears: an import terminates with the bounded
`processing_failed` code and a source-free public message, services stay
healthy, a baseline import is untouched, and a retry succeeds once the
provider returns. The settings API never returns key material (only the
`api_key_configured` boolean). Rotating a provider credential against a
live provider remains gated on a real provider and is recorded as such in
the [deployment profile decisions](./docs/ARCHITECTURE.md#deployment-profile-decisions).
### LLM Security
- User input passed to LLM prompts includes behavioral constraints
- System prompts instruct models to stay in character and refuse harmful content
- Shared provider requests have a 10-second connect timeout, 5-minute total
deadline, 1 MiB JSON response ceiling, bounded Retry-After, and normalized
provider errors
- Chat rendering suppresses model-authored Markdown image requests
- This is defense-in-depth — prompt injection is not fully preventable
### Known Limitations
- Refresh tokens stored in plaintext (not hashed) — acceptable for self-hosted
- No CSRF protection (API-only, no cookie auth)
- LLM prompt injection cannot be fully mitigated at the application layer
- Provider logs, provider-hosted generated images, and operator backups are
outside the application-layer erasure transaction
- Account export snapshots are service-local and sequential, not a globally
atomic database backup