# Security

This page is organized the way a security review of a collaborative editor
usually goes: the questions your reviewers will ask, and the mechanisms in
this server that answer them. Everything described here is in the shipped
code (`packages/server/src/` and the protocol package), not aspirational.

The threat model: **every connected browser is potentially hostile.** The
server never trusts client input beyond "the token resolved to an identity
that is allowed to do this, and the payload is safe to re-materialize in
other people's DOM".

## "Can a client inject script into other users' browsers?" { #the-content-firewall }

No. This is what the **content firewall** is for.

Patches submitted by one user are re-materialized in the DOM of every other
participant. Schema validation (zod) checks _shape_; the firewall
(`packages/server/src/sanitize.ts`) checks _safety_ of every inserted node
tree, every `attr` patch and every text splice **before** the batch is
sequenced or persisted.

### An allowlist, backed by a maintained library

The safety decision is delegated to
[`sanitize-html`](https://www.npmjs.com/package/sanitize-html), a
maintained, allowlist-based sanitizer, **not** a hand-rolled denylist
(denylists are routinely bypassed by obfuscated schemes and encodings). The
firewall serializes each inserted subtree to HTML and runs it through the
library twice: once keeping everything (canonical formatting), once with the
strict allowlist. If the two differ, the strict pass removed something
unsafe → the batch is rejected. The same check validates `attr` patches via a
synthetic element.

| Allowed               | Rule                                                                                                                                                                                                                                                            |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Tags                  | A rich-text allowlist (`p`, `span`, `a`, `strong`, `em`, headings, lists, tables, `img`, `figure`, `video`/`audio`, `font`, …). Everything else (`script`, `iframe`, `object`, `embed`, `style`, `form`, `marquee`, unknown tags) is off the list and rejected. |
| Attributes            | A safe global allowlist (`href`, `src`, `alt`, `title`, `class`, `style`, `colspan`, `data-jodit-comment`, …). Event handlers (`on*`), `srcdoc`, `formaction` and anything else are not on it.                                                                  |
| URL schemes           | `http`, `https`, `mailto`, `tel` (plus `data:` **only** on `img`). `javascript:`, `vbscript:`, `data:text/html`, and obfuscations like `java&#9;script:` or mixed case are normalized by the library and rejected.                                              |
| CSS                   | An explicit guard on top of the library rejects `expression(` / `javascript:` / `vbscript:` inside a `style` value (the library does not filter CSS by default).                                                                                                |
| Nesting depth         | Inserted subtrees deeper than **64** levels are rejected (recursion-bomb protection for every tree walker downstream).                                                                                                                                          |
| Text size             | A single text node / text insert longer than **200 000** characters is rejected.                                                                                                                                                                                |
| Tag / attribute names | Must match `^[a-zA-Z][a-zA-Z0-9:-]*$`; a crafted name like `a><script` is rejected before it can break out of the markup the firewall builds (serialization-injection guard).                                                                                   |

Because peers materialize patches with `createElement` / `setAttribute`
(**never** `innerHTML`), whole classes of attacks that rely on HTML parsing
(mutation XSS, entity-encoded schemes, parser confusion) do not apply: an
attribute value is set literally, so `&#106;avascript:` is an inert string,
not a scheme. The remaining surface is exactly disallowed tags, disallowed
attributes and dangerous URL schemes, which the allowlist covers.

### Why reject the whole batch instead of cleaning it up?

The verdict is **reject-whole-batch** (`error {code: "forbidden", context:
"submit"}`), never silent fix-up. This is deliberate. The sync protocol is
optimistic: the author's editor has already applied the batch locally and
expects the sequenced broadcast to match what it submitted. If the server
silently altered the batch, the author's local state would permanently
diverge from the canonical document (a desync), which the OT machinery
cannot repair.

Honest Jodit editing never produces forbidden content, so a violation means
either an attack or a truly broken client; in both cases the right outcome
is the same: the batch is refused and the reference client **auto-resyncs**
from a fresh server snapshot, discarding the unsafe local change.

The firewall can be disabled (`SANITIZE_ENABLED=false`); only do that when
a trusted-content pipeline of your own sits in front of the server.

### Comment bodies are plain text, not sanitized HTML

The content firewall (`checkPatches`) covers document **patches** only.
Comment `body` and `quote` are length-capped by the zod schemas but **not**
HTML-sanitized: by design they are **plain text**, not rich content. Clients
**MUST render them as text**: the reference `CommentsPanel` uses `textContent`
/ `createTextNode`, so any markup a user types appears literally. A custom
client that injects a comment body as HTML (e.g. `innerHTML`) would open an XSS
hole; that is a client-side responsibility, not something the server filters.

## "Can a client impersonate another user?"

No. **Identity is server-assigned, always.**

- The client's `hello` carries an opaque token and nothing else; presence
  carries only a selection; comment messages carry ids and bodies only.
- Client→server schemas are **strict**: a message with a `name`, `color`,
  `authorName` or any other unknown field is a `bad_message` validation
  error, not a suggestion the server ignores.
- Names, colors, roles and comment authorship come exclusively from
  server→client messages (`welcome`, enriched `presence`, `comment.updated`
  broadcasts), built from the identity resolved by `checkAuthentication` on
  the server.

Details in [Authentication & identity](authentication.md).

## "What happens with no auth configured?"

The server **fails closed**. With no `checkAuthentication` hook and
`ALLOW_ANONYMOUS` off (the default), every connection is rejected with
`auth_required`.

An explicitly presented but invalid token is rejected with `auth_failed`
even in anonymous mode, **but only when a `checkAuthentication` hook is
configured**. That is what stops a broken or expired token from being
silently downgraded to guest access. Without a hook there is nothing to
validate against: anonymous mode then treats any presented token merely as a
seed for the generated guest identity (evaluation only). Wire in a hook for
real token checks.

Authorization is **per operation**, not per connection: `read` on join,
`write` on every submit, `comment` on every comment operation, plus
ownership rules for comment edit/delete. See the
[per-operation table](authentication.md#per-operation-authorization).

## Rate limiting

Each WS connection gets its own token buckets
(`packages/server/src/rate-limit.ts`):

| Bucket   | Default                      | Applies to                                                   | Over limit                                                              |
| -------- | ---------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------- |
| Submits  | 600/min sustained, burst 100 | `submit` **and** every `comment.*` operation (shared bucket) | `error {code: "rate_limited"}`, message dropped, connection stays open  |
| Presence | 25/s                         | `presence`                                                   | Silently dropped (ephemeral data; the next update supersedes it anyway) |

Honest typing is naturally throttled by the one-inflight-batch client
protocol (roughly one submit per round trip), so these defaults never touch
legitimate traffic; they exist to stop floods. Tunable via `RATE_LIMIT_*`
env variables ([Configuration](configuration.md)); the limiter is
per-connection and in-process (a shared multi-node limiter is future work).

The buckets live on the connection, so they **reset on reconnect**: they
throttle a single socket, not a client that keeps opening new ones. On
untrusted networks add a **per-IP connection/upgrade cap** at the reverse
proxy or load balancer in front of the server to bound the reconnect churn a
single source can create.

## Transport hardening

- **Handshake deadline**: the first message must be a valid `hello` within
  **10 seconds**, or the connection is closed (`auth_required`). Malformed
  messages before a successful `hello` also close the connection.
- **Frame size cap**: WS messages larger than **1 MiB** are rejected at the
  socket layer.
- **Path check**: upgrade requests to anything but `${routePrefix}/ws` are
  destroyed.
- **Keepalive reaper**: every 30 seconds the server pings every socket;
  browsers answer WS pings automatically, so a socket that misses two
  rounds (crashed tab, dropped network, half-open TCP) is `terminate()`d
  and its room slot freed. Dead connections cannot pile up.

## Memory bounds

A malicious (or merely huge) workload cannot grow server memory without
limit:

- **Session window**: per document, only the last `SESSION_WINDOW`
  (default 512) entries and their intermediate states stay in memory: the
  [sliding window](protocol.md#base_seq_too_old-and-the-sliding-memory-window)
  needed to transform late submits. Older states are trimmed; clients that
  fall behind the window get `base_seq_too_old` and re-join from a
  snapshot.
- **Batch and message caps**: a submit carries at most 1024 patches, a text
  insert at most 200 000 characters, a comment body at most 10 000, a frame
  at most 1 MiB.
- **Room cap**: the number of distinct documents held in memory is capped at
  `MAX_ROOMS` (default 10 000; `0` = unlimited). Once the cap is reached, a
  `hello` for a **new** document is answered with `error {rate_limited}` and
  the connection is closed: a backstop against a client flooding the server
  with unlimited distinct docIds (unbounded rooms would otherwise exhaust
  memory). Existing rooms always load; only new-room creation is capped.
- **Empty-room TTL**: rooms with no participants are unloaded after
  `EMPTY_ROOM_TTL_MS` (default 10 minutes); the document stays in storage.

Storage growth is bounded separately with `RETENTION_SNAPSHOTS` (prune the
patch log to the last N snapshots); see
[Configuration](configuration.md) and [Storage](storage.md).

## Operator responsibilities

A few controls are deliberately left to the deployment rather than baked into
the server; a reviewer will expect them handled at the proxy/ops layer:

- **Token in the history URL**: the history REST endpoint accepts the token
  as either `?token=` or an `Authorization: Bearer` header. Prefer the
  **header**: a query-string token can leak into proxy/load-balancer access
  logs and browser history.
- **REST rate limiting**: the history endpoint is authenticated but **not**
  rate-limited in-process (only WS connections get token buckets). Rate-limit
  it at the proxy if it is reachable by untrusted callers.
- **Audit logging**: the server logs operational errors, but recording
  security events (auth failures, rejected batches) for audit and alerting is
  the operator's responsibility; capture them from your proxy/log pipeline.
- **CSS injection surface**: the firewall allows the `style` attribute (minus
  `expression()` / `javascript:` / `vbscript:`). Non-script CSS is therefore
  accepted, so a hostile peer could still inject layout CSS (e.g. a
  `position:fixed` overlay). If that matters for your content, strip `style`
  in a front-of-server pipeline.

## Process-level crash guards

The CLI entry point (`run.js`, used by the Docker image) installs
last-resort guards:

- `unhandledRejection`: logged loudly; the process keeps serving (a single
  failed async path must not take down every room);
- `uncaughtException`: logged, then `process.exit(1)`; the process state
  is unknowable, so it lets the container restart it (`restart:
unless-stopped` in the shipped compose file / your orchestrator policy).

This is safe because of the persistence ordering guarantee: every sequenced
entry is written to storage **before** it is broadcast, so a crash-restart
never loses an acknowledged edit; clients reconnect automatically and
resume via `sinceSeq`.

`SIGINT`/`SIGTERM` trigger a graceful shutdown (close sockets, stop
sweepers, drain the storage pool).

## Review checklist

| A reviewer asks…                         | Answer                                                                                                                          |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| XSS via collaborative content?           | Content firewall (allowlist via `sanitize-html`) rejects anything outside a rich-text allowlist server-side, before sequencing. |
| Impersonation / spoofed authorship?      | Impossible by schema: identity fields are not accepted from clients; strict zod schemas.                                        |
| Unauthenticated access?                  | Fail-closed: no hooks + no anonymous flag = every connection rejected.                                                          |
| Privilege escalation between operations? | Every submit and every comment operation is authorized individually; comment ownership checked per thread.                      |
| Flooding / DoS from one client?          | Token-bucket rate limits, 1 MiB frames, 1024-patch batches, hello deadline, keepalive reaper.                                   |
| Unbounded memory?                        | Sliding session window, empty-room TTL, size caps everywhere.                                                                   |
| Data loss on crash?                      | Persist-before-broadcast; idempotent restart; `(doc_id, seq)` primary key prevents forked history.                              |
| Transport encryption?                    | Terminate TLS in front (`wss://`); see [Deployment](deployment.md) for the nginx template.                                      |
