Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
dffcbc1
Limit resend-verification mail per address and per host, as password …
codeGlaze Nov 4, 2026
e082c3b
Test password reset limits per address and per host and the uniform a…
codeGlaze Nov 4, 2026
07cff99
Ask for a Cloudflare Turnstile check on sign-up when both keys are set
codeGlaze Nov 4, 2026
e902c87
Move the Turnstile server check into security and its tests with it
codeGlaze Nov 4, 2026
1e10700
Move the account and sign-in handlers out of routes into routes.auth
codeGlaze Nov 4, 2026
5368b96
Move the shared form helpers from views into components
codeGlaze Nov 4, 2026
1514cdd
Move the account screens and the Turnstile widget into views/auth
codeGlaze Nov 4, 2026
97a6173
Move the account events into events/auth
codeGlaze Nov 4, 2026
1563000
Move page wrappers into orcpub.index and reset-password-page into rou…
codeGlaze Nov 4, 2026
1f5a4b3
Move the auth card's inline styles into Garden
codeGlaze Nov 4, 2026
7eae494
Reset the Turnstile widget through an effect
codeGlaze Nov 4, 2026
790f342
Add one guard table for the account endpoints and a test against the …
codeGlaze Nov 4, 2026
90116b1
Put Turnstile on password reset, resend verification and sign-in afte…
codeGlaze Nov 4, 2026
cee9f0f
Say so when the security check cannot load, and tell a missing token …
codeGlaze Nov 4, 2026
ead18a5
Sign-up no longer reveals a taken email: same answer, the owner is ma…
codeGlaze Nov 4, 2026
93cdb2d
Let the security-check warning be dismissed
codeGlaze Nov 4, 2026
8bc2b6e
Auth forms: one policy table and one guard
codeGlaze Nov 4, 2026
9d7c564
Owner notices: one claim-notice! with a cadence
codeGlaze Nov 4, 2026
1aa919e
Auth forms: the page shell and one request interceptor carry the prot…
codeGlaze Nov 4, 2026
710a241
Take the login challenge flag from the guard table
codeGlaze Nov 4, 2026
58cf1c5
Branch changelog for fix/auth-abuse
codeGlaze Nov 4, 2026
cbbe7a9
Close auth-abuse review findings: configured-URL notices, Turnstile 4…
codeGlaze Nov 4, 2026
ede36a0
Build account-mail links from the configured site URL
codeGlaze Nov 4, 2026
8aedfe9
Put the abuse limits' state behind a LimitStore, with an optional sha…
codeGlaze Nov 4, 2026
debc73a
Close the verified-account oracle in re-verify and tidy auth follow-ups
codeGlaze Nov 5, 2026
c9dbe1e
Drop the dead X-Frame-Options ALLOW-FROM header from character pages
codeGlaze Nov 5, 2026
d1440cd
Fold the fix/auth-abuse changelog into the Summer Patch
codeGlaze Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,15 @@ PROFILE_ENCRYPTION_KEYS=
# ORCPUB_SHARE_PRUNE_DAYS=180
# Check new passwords against known breaches (Have I Been Pwned). on or off. Default on.
# ORCPUB_PWNED_CHECK=on
# Cloudflare Turnstile (managed mode) on sign-up, password reset, resend verification and sign-in
# after a failed attempt. Off unless BOTH are set. Fails open if Cloudflare cannot be reached. Cloudflare's test keys always pass:
# site key 1x00000000000000000000AA secret 1x0000000000000000000000000000000AA
# TURNSTILE_SITE_KEY=
# TURNSTILE_SECRET_KEY=
# Where the abuse limits are counted. Optional: leave unset to count in each app container's own
# memory. Set it when MORE THAN ONE app container serves the site, so the limits are shared
# (redis://[:password@]host:6379). If Redis fails, each container counts in memory meanwhile.
# REDIS_URL=

# --- Branding (optional) ---
# The site's name, logos, links and email identity. Each has a default in fork/branding.clj;
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte
- **Characters:** the portrait credit links to the artist's page.
- **Accounts:** artists choose on My Account whether they have a page, and whether it shows
their example portraits, their links and how many pieces they drew.
- **Accounts:** an optional Cloudflare Turnstile check on sign-up, password reset and resend verification, and on sign-in after a failed attempt. Off unless `TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET_KEY` are set; if Cloudflare can't be reached, requests go through.
- **Accounts:** when the security check can't load, the form says so and suggests allowing challenges.cloudflare.com in an ad blocker.

### Changed
- **Server:** a deployed server will not start without a mail server unless `EMAIL_REQUIRED=false` says that is intended, and the message names the setting to add. Development mode is exempt; `./run` and `./run --upgrade` set the opt-out for installs that run without email, so none stops on upgrade. (#716)
Expand Down Expand Up @@ -139,6 +141,10 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte
for repair.
- **Homebrew:** checking homebrew after an import or a Restore takes a fraction of the time for
classes: each shared part of a class is checked once, not once per level.
- **Accounts:** sign-up no longer says when an email is already in use. It answers as for a new address and emails the account's owner instead. Without a mail server, a taken address gets a plain refusal.
- **Accounts:** warnings about sign-in attempts on your account now follow up while attempts continue (at most every 6 hours, with a count), instead of once a day.
- **Server:** links in account emails use `APP_URL` when it is set (set it in production; the startup report warns when it is not).
- **Server:** an optional `REDIS_URL` shares the sign-in and email limits across app containers; without it each container keeps its own.

### Fixed

Expand Down Expand Up @@ -253,6 +259,7 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte
- Sign-in, password reset, share links, image fetching and PDF export are hardened against misuse, and secrets are no longer printed at startup.
- **Site:** pages no longer load fonts from Google, so visiting the site no longer sends your address to Google. (#721)
- **Site:** other sites a page links to are told only this site's domain, never the page's address.
- **Accounts:** account emails can no longer be triggered in bulk from one form that had no limit.

## [breaking/2026-stack-modernization]

Expand Down
11 changes: 6 additions & 5 deletions dev/user.clj
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
[datomic.api :as datomic]
[buddy.hashers :as hashers]
[orcpub.routes :as r]
[orcpub.routes.auth :as auth]
[orcpub.system :as s]
[orcpub.common :as common]
[orcpub.server :as server]
Expand Down Expand Up @@ -125,9 +126,9 @@
since the email never gets sent."
[username-or-email]
(with-db [conn db]
(let [user (r/find-user-by-username-or-email db username-or-email)
(let [user (auth/find-user-by-username-or-email db username-or-email)
verification-key (:orcpub.user/verification-key user)]
(r/verify {:query-params {:key verification-key}
(auth/verify {:query-params {:key verification-key}
:conn conn
:db db}))))

Expand Down Expand Up @@ -170,7 +171,7 @@
(let [username "test"
email "test@example.com"
password "testpass"]
(r/register {:username username :email email :password password :verified true}))
(auth/register {:username username :email email :password password :verified true}))
(println "add-test-user is disabled outside dev environment.")))

;; ---------------------------------------------------------------------------
Expand Down Expand Up @@ -236,7 +237,7 @@
Does not require a running server."
[conn username-or-email]
(let [db (datomic/db conn)
user (r/find-user-by-username-or-email db username-or-email)]
user (auth/find-user-by-username-or-email db username-or-email)]
(if-not user
(throw (ex-info "User not found" {:username-or-email username-or-email}))
@(datomic/transact conn [[:db/add (:db/id user) :orcpub.user/verified? true]]))))
Expand All @@ -245,7 +246,7 @@
"Remove a user entity by username or email. Dev only."
[conn username-or-email]
(let [db (datomic/db conn)
user (r/find-user-by-username-or-email db username-or-email)]
user (auth/find-user-by-username-or-email db username-or-email)]
(if-not user
(throw (ex-info "User not found" {:username-or-email username-or-email}))
@(datomic/transact conn [[:db/retractEntity (:db/id user)]]))))
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ services:
ORCPUB_SHARE_MAX_ACCOUNT_KB: ${ORCPUB_SHARE_MAX_ACCOUNT_KB:-}
ORCPUB_SHARE_PRUNE_DAYS: ${ORCPUB_SHARE_PRUNE_DAYS:-}
ORCPUB_PWNED_CHECK: ${ORCPUB_PWNED_CHECK:-}
TURNSTILE_SITE_KEY: ${TURNSTILE_SITE_KEY:-}
TURNSTILE_SECRET_KEY: ${TURNSTILE_SECRET_KEY:-}
# Optional: share the abuse limits between app containers (no Redis service is added here)
REDIS_URL: ${REDIS_URL:-}
# Branding (docs/BRANDING-AND-INTEGRATIONS.md). Set, these override the branding file's defaults
APP_NAME: ${APP_NAME:-}
APP_TAGLINE: ${APP_TAGLINE:-}
Expand Down
6 changes: 5 additions & 1 deletion docs/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ start-up output, so you can decide which ones matter for your site.
| `ORCPUB_SHARE_MAX_UPLOAD_KB`, `ORCPUB_SHARE_MAX_TEXT_KB`, `ORCPUB_SHARE_MAX_ACCOUNT_KB` | How much homebrew a share link and an account may keep | [Share links](#share-links) |
| `ORCPUB_SHARE_PRUNE_DAYS` | How long an unused share link is kept | [Share links](#share-links) |
| `ORCPUB_PWNED_CHECK` | Whether new passwords are checked against known breaches | [Security](#security) |
| `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile bot check on sign-up, password reset, resend verification and sign-in after a failure | [Security](#security) |
| `TRUST_CLOUDFLARE`, `TRUSTED_PROXIES` | Which proxies to trust when finding a player's real address | [Security](#security) |
| `APP_EMAIL_SIGNOFF` | Who the friendlier emails are signed from | [BRANDING-AND-INTEGRATIONS.md](BRANDING-AND-INTEGRATIONS.md) |

Expand Down Expand Up @@ -201,7 +202,7 @@ the table in half. Plain ASCII, no colour: it is read in aggregators as often as
| Variable | Default | Description |
|----------|---------|-------------|
| `PORTRAIT_ARTISTS` | — | JSON of artist credits and accounts (see `ARTISTS.md`). An `account` email gets an artist account and a welcome email at startup. |
| `APP_URL` | — | The site's address, used for links in artist emails (sent at startup, with no request to read it from). |
| `APP_URL` | — | The site's address, used for links in artist emails (sent at startup, with no request to read it from) and in every account email (verification, password reset, notices). Set it in production: unset, those links follow the request's Host header, which a sender can forge, and the boot report warns. |
| `EMAIL_ADMIN_TO` | `EMAIL_ERRORS_TO`, then `APP_SUPPORT_EMAIL` | Who hears about artist account and credit changes. |
| `PORTRAIT_STORE_DIR` | `orcpub-portraits` in the system temp directory | Where the server keeps its drawings of shared-link portraits, one file per character. A shared volume lets several containers draw each portrait once; without one each container draws it on first request and keeps the file. In Docker, mount a shared volume at `/var/lib/orcpub/portraits` (the image creates it for the app's user) and set this to that path. A failed write is logged and the drawing is still served. Derived files: losing them only means drawing again. |

Expand All @@ -212,6 +213,9 @@ the table in half. Plain ASCII, no colour: it is read in aggregators as often as
| `CSP_POLICY` | `strict` | Content Security Policy mode: `strict`, `permissive`, or `none` |
| `DEV_MODE` | `"true"` (in :dev profile) | When true, **no CSP header is sent at all** — that is what lets Figwheel's `ws://localhost:3449` through. Must be the string `"true"` (case-insensitive); any other value, including `"1"`, `"yes"` or empty, is false. |
| `ORCPUB_PWNED_CHECK` | `on` | Screen new passwords against the Have I Been Pwned corpus. Accepts `on`/`off`, `true`/`false`, `1`/`0`, `yes`/`no`. |
| `TURNSTILE_SITE_KEY` | unset | Cloudflare Turnstile site key (public; sent to the browser). The managed-mode check appears on sign-up, password reset, resend verification, and sign-in after a failed attempt, only when **both** this and `TURNSTILE_SECRET_KEY` are set; otherwise the feature is entirely off. Adds `https://challenges.cloudflare.com` to the CSP `frame-src` (and, under `CSP_POLICY=permissive`, `script-src`). Fails open: if Cloudflare cannot be reached (or answers with a server error), the request goes through; a refusal from Cloudflare (4xx) or a missing token does not. For testing, Cloudflare's dummy keys always pass: site key `1x00000000000000000000AA`, secret `1x0000000000000000000000000000000AA`. |
| `TURNSTILE_SECRET_KEY` | unset | Turnstile secret key; server-side only, never logged or shown (the startup report says only set / NOT SET). See `TURNSTILE_SITE_KEY`. |
| `REDIS_URL` | unset | Optional. Where sign-in, sign-up and reset limits, sign-in challenge marks and notice cadences are counted. Unset, each app container counts in its own memory (right for one container). Set it (`redis://[:password@]host:6379`) when more than one app container serves the site, so the limits are shared. The URL may carry a password, so the startup report says only set / NOT SET. If Redis cannot be reached (200 ms timeout), that call is counted in memory instead and a warning is logged at most once a minute: limits never switch off. No Redis service is added to the compose files; the admin provides one. |
| `TRUST_CLOUDFLARE` | unset | Set to `true` only when the site is served through Cloudflare. The sign-in, sign-up and reset limits and the security log then see each player's real address instead of Cloudflare's. Leave it unset otherwise: anyone can send requests from Cloudflare's addresses, so trusting them on a site not behind Cloudflare would let a visitor pick their own address. |
| `TRUSTED_PROXIES` | unset | Other proxies in front of the app, as comma-separated addresses or ranges (`203.0.113.10,198.51.100.0/24`). Private networks, where Docker, Swarm, Traefik and nginx proxies sit, are always trusted. |
| `PROFILE_ENCRYPTION_KEYS` | — | Encrypts preferred names at rest: comma-separated `id:base64` keys of 32 bytes; the first encrypts, all decrypt. Also read from the Docker secret `profile_encryption_keys`. Unset = preferred names cannot be saved. Losing it makes stored names unreadable. `./run` generates one. |
Expand Down
2 changes: 2 additions & 0 deletions project.clj
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
[org.clojure/clojurescript "1.12.134"]
[org.clojure/core.async "1.8.741"]
[org.postgresql/postgresql "42.7.3"]
;; Redis client, used only when REDIS_URL is set (orcpub.security.redis-store).
[com.taoensso/carmine "3.4.1"]
;; React 18 + Reagent 2.0 (Concurrent Mode)
[cljsjs/react "18.3.1-1"]
[cljsjs/react-dom "18.3.1-1"]
Expand Down
7 changes: 4 additions & 3 deletions scripts/e2e/auth-flows.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ async function resetLinkFor(page, sink, email) {
check((await heading(page)).toLowerCase().includes('registration is complete'),
'a new account registers and verifies', await heading(page));

// The same address and name a second time must be refused, per field.
// The same name a second time is refused, per field. The address is NOT: sign-up never says an
// address is taken (it answers "check your email" and mails the owner instead).
await page.goto(`${BASE}/pages/register-page`, { waitUntil: 'networkidle' });
await page.waitForSelector('.auth-form input');
let b = authInputs(page);
Expand All @@ -127,8 +128,8 @@ async function resetLinkFor(page, sink, email) {
// GOTCHA: matches the server's messages; the page always shows "Already have an account?".
await submit(page, () => press(page, 'JOIN'), /already taken by another user/);
const taken = await cardText(page);
check(/already taken by another user/i.test(taken) && /already associated with another account/i.test(taken),
'a username and email already in use are refused',
check(/already taken by another user/i.test(taken) && !/already associated|already in use|taken/i.test(taken.replace(/already taken by another user/ig, '')),
'a username already in use is refused, and the email is not named as taken',
taken.replace(/\n/g, ' | ').slice(0, 120));

// Short password: the rules must say what is wrong, not just refuse.
Expand Down
3 changes: 3 additions & 0 deletions scripts/swarm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,9 @@ generate_swarm_compose() {
"ORCPUB_SHARE_MAX_ACCOUNT_KB:\${ORCPUB_SHARE_MAX_ACCOUNT_KB:-}"
"ORCPUB_SHARE_PRUNE_DAYS:\${ORCPUB_SHARE_PRUNE_DAYS:-}"
"ORCPUB_PWNED_CHECK:\${ORCPUB_PWNED_CHECK:-}"
"TURNSTILE_SITE_KEY:\${TURNSTILE_SITE_KEY:-}"
"TURNSTILE_SECRET_KEY:\${TURNSTILE_SECRET_KEY:-}"
"REDIS_URL:\${REDIS_URL:-}"
"APP_NAME:\${APP_NAME:-}"
"APP_TAGLINE:\${APP_TAGLINE:-}"
"APP_PAGE_TITLE:\${APP_PAGE_TITLE:-}"
Expand Down
Loading
Loading