Skip to content

Commit ce3489a

Browse files
claudejouwdan
authored andcommitted
docs: document search, the word filter, and what the board puts in a browser
MEI-124 pass two, finished. Three pages, and one corrected claim. docs/guides/community/search.md — the four controls, and the fact that makes switching search off safe to try: the index is kept and goes on being maintained, so turning it back on needs no reindex. Also separates the two limits people conflate, since setting one does nothing about the other: the flood interval is per member and about impatience, the hourly cap is aimed at a script. docs/guides/community/word-filter.md — a pattern is a literal, not a wildcard, so `.*` matches two characters and nothing else. The filter runs at render time and never edits stored text, which is why removing a rule brings the original word back everywhere and why a quote of a filtered post carries the original. docs/guides/operations/cookies-and-headers.md — every cookie, why it exists, and why SameSite differs by purpose: Lax for the SSO handshake because a Strict cookie is not sent on the identity provider's navigation back, Strict for the admin, second-factor and passkey exchanges because none of those ever start elsewhere. The nonce CSP, and REMOTE_IMAGES as the one environment variable that widens it. The corrected claim: moderation-guide.md said the word filter rewrites words "wherever a post is shown — thread pages, excerpts, feeds, search results". All seven filterWords call sites guard a body, an excerpt or a summary; none guards a title. A filtered word stays visible in every forum listing and beside its own filtered excerpt in search results. The guide now says so, and the gap is filed separately. Verified before writing rather than reproduced: searchScopeFor really is built from the forum audience, and staff really do see unapproved and deleted posts in results; the flood interval is bypassed by the flood.bypass permission; REMOTE_IMAGES is what adds https: to img-src.
1 parent d7471c5 commit ce3489a

7 files changed

Lines changed: 313 additions & 6 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,11 @@ The table below is generated from `apps/web/content/docs.manifest.json` by `pnpm
9393
| Configuration & guides | [`guides/community/groups.md`](./docs/guides/community/groups.md) | What a group carries: board-wide permissions, the daily allowances, colours and badges, display groups, the groups a plugin may grant, and the rules that promote members automatically. |
9494
| Configuration & guides | [`guides/community/antispam.md`](./docs/guides/community/antispam.md) | The registration challenge, the limits on pages nobody has signed in to, the three login counters, and the hourly allowances — what each is worth and what it costs a real visitor. |
9595
| Configuration & guides | [`guides/community/reputation.md`](./docs/guides/community/reputation.md) | Members rating each other: what a rating is worth, the four settings, the two that decide between a one-press thanks and a form, and why the totals cannot drift. |
96+
| Configuration & guides | [`guides/community/search.md`](./docs/guides/community/search.md) | The four controls over the most expensive thing a visitor can ask for: switching search off without losing the index, the shortest word a query may rest on, and the two different limits on how often. |
97+
| Configuration & guides | [`guides/community/word-filter.md`](./docs/guides/community/word-filter.md) | Rewriting words as a page renders, never in the stored post: what a rule is, why a pattern is a literal rather than a wildcard, and exactly which surfaces it covers. |
9698
| Configuration & guides | [`guides/operations/operating.md`](./docs/guides/operations/operating.md) | Routine checks, configuration, maintenance commands, backups, recovery, and troubleshooting for a running board. |
9799
| Configuration & guides | [`guides/operations/monitoring.md`](./docs/guides/operations/monitoring.md) | Liveness versus readiness, the optional Prometheus metrics endpoint and what to alert on, distributed tracing, and shipping logs. |
100+
| Configuration & guides | [`guides/operations/cookies-and-headers.md`](./docs/guides/operations/cookies-and-headers.md) | What the board puts in a visitor's browser and what it tells the browser to refuse: every cookie and why it exists, the nonce Content Security Policy, and the one environment variable that changes it. |
98101
| Configuration & guides | [`guides/operations/upgrading.md`](./docs/guides/operations/upgrading.md) | Moving a board between versions: the upgrade command, how far you can jump, and the behaviour changes each release brings. |
99102
| Configuration & guides | [`guides/operations/disaster-recovery.md`](./docs/guides/operations/disaster-recovery.md) | The runbook for the day the server is gone: what recovery consumes, the order of operations from provisioning to DNS, and the rehearsal that turns it from a hope into a plan. |
100103
| Configuration & guides | [`guides/operations/single-sign-on.md`](./docs/guides/operations/single-sign-on.md) | Two-factor authentication, federated sign-in and passkeys: what each means for your members, how to configure them, and the record of what has opened an account. |

apps/web/content/docs.manifest.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,26 @@
198198
"primary": false,
199199
"group": "Running your community"
200200
},
201+
{
202+
"slug": "search",
203+
"file": "guides/community/search.md",
204+
"section": "guides",
205+
"title": "Search",
206+
"blurb": "The four controls over the most expensive thing a visitor can ask for: switching search off without losing the index, the shortest word a query may rest on, and the two different limits on how often.",
207+
"generated": false,
208+
"primary": false,
209+
"group": "Running your community"
210+
},
211+
{
212+
"slug": "word-filter",
213+
"file": "guides/community/word-filter.md",
214+
"section": "guides",
215+
"title": "The word filter",
216+
"blurb": "Rewriting words as a page renders, never in the stored post: what a rule is, why a pattern is a literal rather than a wildcard, and exactly which surfaces it covers.",
217+
"generated": false,
218+
"primary": false,
219+
"group": "Running your community"
220+
},
201221
{
202222
"slug": "operating",
203223
"file": "guides/operations/operating.md",
@@ -218,6 +238,16 @@
218238
"primary": false,
219239
"group": "Operating the server"
220240
},
241+
{
242+
"slug": "cookies-and-headers",
243+
"file": "guides/operations/cookies-and-headers.md",
244+
"section": "guides",
245+
"title": "Cookies and security headers",
246+
"blurb": "What the board puts in a visitor's browser and what it tells the browser to refuse: every cookie and why it exists, the nonce Content Security Policy, and the one environment variable that changes it.",
247+
"generated": false,
248+
"primary": false,
249+
"group": "Operating the server"
250+
},
221251
{
222252
"slug": "upgrading",
223253
"file": "guides/operations/upgrading.md",

docs/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,14 @@ Running your community — browser only, no shell access:
3737
- [Groups and promotions](./guides/community/groups.md) — what a group carries board-wide.
3838
- [Spam controls and rate limits](./guides/community/antispam.md) — the registration challenge and every threshold.
3939
- [Reputation](./guides/community/reputation.md) — members rating each other, and the two settings that decide what a post offers.
40+
- [Search](./guides/community/search.md) — switching it off without losing the index, and the two limits on how often.
41+
- [The word filter](./guides/community/word-filter.md) — rewriting words at render time, and exactly which surfaces it covers.
4042

4143
Operating the server:
4244

4345
- [Operations](./guides/operations/operating.md) — health checks, configuration, CLI commands, mail, backups, and troubleshooting.
4446
- [Monitoring & alerting](./guides/operations/monitoring.md) — liveness versus readiness, metrics, tracing, and logs.
47+
- [Cookies and security headers](./guides/operations/cookies-and-headers.md) — every cookie the board sets, and the Content Security Policy.
4548
- [Upgrading](./guides/operations/upgrading.md) — move between released versions safely.
4649
- [Disaster recovery](./guides/operations/disaster-recovery.md) — restore a board when the original server is unavailable.
4750
- [Signing in](./guides/operations/single-sign-on.md) — passwords, two-factor authentication, federated sign-in, passkeys, and sessions.

docs/guides/community/moderation-guide.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -248,12 +248,13 @@ system working, not a gap in your permissions.
248248
Two things do quiet work beside you, and neither needs anything from
249249
you day to day:
250250

251-
- **The word filter** rewrites listed words wherever a post is shown —
252-
thread pages, excerpts, feeds, search results — without ever changing
253-
what is stored. You see the original in the queue and on the report
254-
screens, because you are judging the words. Adding to the filter is
255-
the administrator's job, and a change applies everywhere on the next
256-
page load.
251+
- **[The word filter](./word-filter.md)** rewrites listed words as a
252+
page renders — post bodies, excerpts, feeds and search results —
253+
without ever changing what is stored. **It does not cover thread
254+
titles**, so a title is a rename rather than a filter rule. You see
255+
the original in the queue and on the report screens, because you are
256+
judging the words. Adding to the filter is the administrator's job,
257+
and a change applies everywhere on the next page load.
257258
- **Reputation** lets members rate each other's posts — a thanks button
258259
by default, and a rating form if the board allows rating down. New
259260
members must post a few times before they may rate, which keeps it

docs/guides/community/search.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Search
2+
3+
Search is the most expensive thing a visitor can ask a board to do, and
4+
on a busy board it is the first thing to misbehave. This page is the
5+
four controls over it and what each is actually worth.
6+
7+
Three live under `/admin/settings?group=search`. The fourth is a rate
8+
limit and sits with the [spam controls](./antispam.md).
9+
10+
## Switching search off
11+
12+
**Enable search** does four things at once, and the fourth is the one
13+
that makes it a safe thing to try:
14+
15+
- The **Search link leaves the board navigation**.
16+
- **`/search`**, and any results page still linked to, is replaced with a
17+
line saying search is off.
18+
- **`GET /api/v1/search` answers 403.** An API token is not a way round
19+
the setting.
20+
- **The index is kept, and goes on being maintained.** Posts written
21+
while search is off are still indexed.
22+
23+
That last point is why this is reversible at no cost: switching it back
24+
on needs no reindex and loses nothing. It is a reasonable thing to do
25+
for an afternoon while a board is under load.
26+
27+
## The shortest word a search may rest on
28+
29+
A search is refused unless **at least one of its words** is this long.
30+
The default is 2, so at 3 the search *a good post* runs and *a b c* does
31+
not.
32+
33+
The short words are **not dropped**. They are still sent to the index,
34+
which decides for itself which of them carry no meaning. The setting is
35+
a floor on the query as a whole, not a filter on its words — it exists
36+
to refuse the searches that scan the most and mean the least.
37+
38+
Raise it if the expensive searches on your board turn out to be short
39+
ones. Most boards never need to.
40+
41+
## Two different limits on how often
42+
43+
These are separate controls and they answer different problems. Setting
44+
one does nothing about the other.
45+
46+
**The search flood interval** (`search` settings, 30 seconds by default)
47+
is the minimum gap between one member's searches. It is per member, it
48+
is about impatience, and a member who is over it is told how long to
49+
wait. A group with **bypass flood check** is not subject to it.
50+
51+
**Searches per hour** (`antispam` settings, `0` — off — by default) is
52+
an hourly cap, and it is aimed at the visitor who is not a member at
53+
all. `0` means no cap rather than no searches, as everywhere else in
54+
those settings. See [Spam controls](./antispam.md) for how the hourly
55+
allowances work and what they cost a real visitor.
56+
57+
If a board is struggling under search load, the hourly cap is usually
58+
the one to reach for. The flood interval slows a person down; the hourly
59+
cap is what stops a script.
60+
61+
## What a result shows
62+
63+
Search results carry an excerpt from the post, and **the excerpt has the
64+
[word filter](./word-filter.md) applied**. The thread title beside it
65+
does not — see that page for what the filter covers.
66+
67+
Results obey permissions. The scope is built from the same forum
68+
audience the rest of the board uses, so a member never sees a hit in a
69+
forum they could not open. **Administrators and super moderators also
70+
see unapproved and deleted posts** in results, which is deliberate — it
71+
is how you find something that was hidden — and is worth knowing before
72+
you read a staff member's result count as the board's.
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# The word filter
2+
3+
The word filter rewrites words as a page is rendered. It is the board's
4+
way of taking the sting out of language without editing anybody's post
5+
or standing over the composer.
6+
7+
`/admin/content` holds it, under **Word filters**. It is an
8+
administrator's control: a moderator's route to bad language is the
9+
warning ladder or a hidden post, not this screen.
10+
11+
## What a rule is
12+
13+
Three fields:
14+
15+
- **The pattern** — the word to look for.
16+
- **The replacement** — what to put in its place. It may be empty,
17+
which removes the word.
18+
- **Whole word** — on, the pattern only matches when it stands alone as
19+
a word; off, it matches anywhere inside a longer one.
20+
21+
Matching is **case-insensitive**, and the replacement is inserted
22+
exactly as you typed it. A rule with an empty pattern is ignored.
23+
24+
> [!IMPORTANT]
25+
> **A pattern is a literal, not a pattern language.** Every character is
26+
> matched as itself — `*`, `?`, `.` and the rest are just those
27+
> characters. There are no wildcards and no regular expressions, so
28+
> `.*` matches the two characters `.` and `*` and nothing else. If you
29+
> want to catch several spellings of a word, that is several rules.
30+
31+
**Whole word is the setting that surprises people.** With it off, a rule
32+
for `ass` rewrites the middle of *class*, *passage* and *assessment*.
33+
With it on, only the word on its own is touched. Leave it on unless you
34+
have a reason.
35+
36+
## What it changes, and what it does not
37+
38+
**The filter runs at render time. It never edits stored text.** The post
39+
in the database is exactly what its author typed, and removing a rule
40+
brings the original word back everywhere immediately. Nothing is
41+
destroyed, so a rule is never a decision you have to live with.
42+
43+
Two consequences worth knowing:
44+
45+
- **A member who quotes a filtered post gets the original word**, because
46+
the quote is built from the stored text.
47+
- **The moderation queue deliberately shows text unfiltered** — you are
48+
judging the words, so you see them. See
49+
[the moderator's guide](./moderation-guide.md#the-approval-queue).
50+
51+
The filter only touches the text a reader sees. It steps over HTML tags,
52+
so it never rewrites a link's address, a class name or an attribute — a
53+
rule for `cat` cannot break a link to `example.com/catalogue`.
54+
55+
### Where it applies
56+
57+
| Filtered | Not filtered |
58+
| --- | --- |
59+
| Post bodies | **Thread titles** |
60+
| Excerpts in the latest-posts lists | Signatures |
61+
| Search result excerpts, on the board and through the REST API | Custom profile fields |
62+
| Feed summaries (RSS and Atom) | Usernames |
63+
| The description in a page's metadata, which is what a link preview shows | |
64+
65+
> [!WARNING]
66+
> **Thread titles are not filtered.** A filtered word in a title stays
67+
> visible in every forum listing, in search results beside a filtered
68+
> excerpt, and in the feed. If a title is the problem, rename the thread
69+
> — a moderator can — rather than expecting a rule to cover it. This is
70+
> a gap rather than a decision, and it is tracked.
71+
72+
## What it costs
73+
74+
Very little. The compiled rules are cached board-wide and rebuilt when
75+
you change one, and the substitution runs over text the board was
76+
rendering anyway. A long list of rules is fine; it is one pass per rule
77+
over the visible text of a page.
78+
79+
The real cost is judgement. A filter that rewrites a word into a joke
80+
reads as the board making light of something a member was serious about,
81+
and members can tell the difference between a board that removed a slur
82+
and one that made a punchline of it.
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Cookies and security headers
2+
3+
What the board puts in a visitor's browser, and what it tells the
4+
browser to refuse. This is the page to read when somebody asks what you
5+
store about them, when a cookie banner is being drafted, or when
6+
something on the board is being blocked and you need to know by what.
7+
8+
None of it is configurable from the admin panel. It is part of the
9+
software, and the one thing that changes it is an environment variable,
10+
named below.
11+
12+
## The cookies
13+
14+
**The board sets no third-party cookies, runs no analytics, and stores
15+
nothing for advertising.** Every cookie below is first-party, set by the
16+
board itself, and there to make a specific thing work.
17+
18+
| Cookie | What it is for | Lifetime |
19+
| --- | --- | --- |
20+
| `fs_session` | The signed-in session | Until it expires or you sign out |
21+
| `fs_remember` | *Remember me* on the sign-in form | The remember period |
22+
| `fs_guest` | Counts one reader once, for "who's online" | 1 day |
23+
| `fs_admin` | Admin-panel re-authentication | The admin session |
24+
| `fs_2fa` | A sign-in that has given a password and owes a second factor | Short |
25+
| `fs_sso` | The single sign-on handshake | 10 minutes |
26+
| `fs_passkey` | The passkey exchange | Short |
27+
28+
Every one is **`HttpOnly`** — script cannot read any of them — and every
29+
one is **`Secure`** wherever the board is served over HTTPS. Over HTTPS
30+
they also carry the **`__Host-` prefix** (`__Host-fs_session` and so on),
31+
which binds a cookie to the exact origin that set it and forbids a
32+
subdomain from writing it.
33+
34+
`SameSite` differs by purpose, and the differences are deliberate:
35+
36+
- **`Lax`** for the session, remember, guest and SSO cookies. The SSO
37+
one has to be `Lax`: an identity provider returns the member with a
38+
top-level navigation from another site, and a `Strict` cookie is not
39+
sent on that request, so every federated sign-in would fail.
40+
- **`Strict`** for the admin, second-factor and passkey cookies. Nothing
41+
in those exchanges ever starts on another site, so nothing is lost by
42+
refusing to send them cross-site.
43+
44+
The admin cookie is also **scoped to `/admin`**, so it is not sent with
45+
ordinary board requests at all.
46+
47+
### The guest cookie, and what it is not
48+
49+
`fs_guest` is the only cookie a visitor gets without signing in, and it
50+
exists for one figure: "37 guests reading". That number is not derivable
51+
from a stateless request — without something that comes back, every page
52+
view is a stranger.
53+
54+
It is **an opaque random value and nothing else**. No code path turns it
55+
into an identity, and the session lookup refuses a row with no user
56+
behind it. The most it can say about the person holding it is that they
57+
were here. It lasts a day.
58+
59+
Whether that needs consent where you operate is a question for you, not
60+
for this page — but "strictly necessary" is an argument you can actually
61+
make about it, which is not true of an analytics cookie.
62+
63+
## The Content Security Policy
64+
65+
Every page is served under a **nonce-based policy**, generated fresh per
66+
request:
67+
68+
```
69+
default-src 'self';
70+
img-src 'self' data:;
71+
style-src 'self' 'unsafe-inline';
72+
script-src 'self' 'nonce-<per-request>' 'strict-dynamic';
73+
connect-src 'self'; worker-src 'self'; manifest-src 'self';
74+
frame-ancestors 'self'; object-src 'none';
75+
base-uri 'self'; form-action 'self'
76+
```
77+
78+
What that means in practice:
79+
80+
- **An injected `<script>` does not run.** It has no nonce, and
81+
`'strict-dynamic'` means the browser trusts scripts the board's own
82+
nonced scripts load, and nothing else.
83+
- **Nothing loads from another origin** — no CDN, no font host, no
84+
embedded widget. A theme or plugin that reaches for one will be
85+
blocked, and the browser console will say so.
86+
- **`form-action 'self'`** means a form on your board cannot be made to
87+
post somewhere else.
88+
- **`frame-ancestors 'self'`** means the board cannot be framed by
89+
another site.
90+
91+
**One environment variable changes it.** `REMOTE_IMAGES=1` adds `https:`
92+
to `img-src`, which is what lets members hotlink images from elsewhere.
93+
It is off by default: allowing remote images means every post can make a
94+
reader's browser fetch from a third party, which leaks the reader's IP
95+
address to whoever hosts it.
96+
97+
The e2e suite asserts that every page carries the policy **and that
98+
nothing on the page is refused under it**, so a change that would have
99+
needed `unsafe-inline` fails before it ships.
100+
101+
## The other headers
102+
103+
Sent on every response:
104+
105+
| Header | Value | What it stops |
106+
| --- | --- | --- |
107+
| `X-Content-Type-Options` | `nosniff` | A browser guessing a type and running an upload as script |
108+
| `Referrer-Policy` | `strict-origin-when-cross-origin` | A full path leaking to another site |
109+
| `Strict-Transport-Security` | `max-age=63072000` | A downgrade to HTTP for two years |
110+
| `X-Frame-Options` | `SAMEORIGIN` | Framing, for browsers older than `frame-ancestors` |
111+
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` | Anything asking for hardware the board never uses |
112+
113+
If you terminate TLS at a reverse proxy, it has to pass these through
114+
rather than replace them — see
115+
[Docker Compose](../../getting-started/deployment/docker-compose.md) for
116+
the CSP note on proxying.

0 commit comments

Comments
 (0)