// ── The demo lock (docs/website/DEMO_MODE.md §3) ─────────────────────────── // // With DEMO_MODE on, app.js mounts four middlewares from this file, and with it // off none of them is mounted at all, so a real shard runs none of this code. // // • `refuseMultipart` — any request with a multipart body, anywhere, before a // router sees it (§3.3 "Uploads — locked hard"). An upload route added later // is locked without anyone remembering to list it, and a module cannot open one. // • `lock` — each write request against core's list below plus every module's // declared `demoLocks`, at `/api/v1`, ahead of every router. // • `refuseDataUri` — any write whose JSON body carries a data: URI in any // string (DM25): a pasted image is an upload that did not use multipart. // • `noindex` + `robots` — nothing a visitor writes reaches a search engine. // // **The server is the lock (DM10).** Everything here is decided from the request // alone, so `curl`, an old app build and a hand-edited page are refused exactly // like a click. The client explains a refusal; it never enforces one. // // A refusal is always the same answer, so the web client and the app can each // recognise it by `code` and show one sentence for it. const { compile, match } = require('./paths') const { isDemoUsername } = require('./mode') const log = require('../utils/logger')('demo') const CODE = 'DEMO_LOCKED' const MESSAGE = 'This is turned off on the demo site.' const WRITES = ['POST', 'PUT', 'PATCH', 'DELETE'] function refuse(res, extra = {}) { return res.status(403).json({ message: MESSAGE, code: CODE, ...extra }) } // ── Conditions ───────────────────────────────────────────────────────────── // A few rows lock a route only for some requests. Each condition is asked only // after the method and path have matched, so the DB is touched on a handful of // rare admin and self-service writes and on nothing else. // Required lazily: this file is required by app.js, and the users model would // otherwise make the DB pool a startup dependency of the lock. /* eslint-disable global-require */ const users = () => require('../model/users/users.model') const sessions = () => require('../auth/session.service') /* eslint-enable global-require */ async function isDemoUserId(id) { const n = Number(id) if (!Number.isInteger(n) || n < 1) return false const user = await users().getById(n) return Boolean(user && isDemoUsername(user.username)) } // `PUT /admin/users/:id` and its kin: an admin acting on one of the shared logins. const targetIsDemo = (req, params) => isDemoUserId(params.id) // `/auth/me/*`: the caller signed in as one of the shared logins. The identity is // read the way requireAuth reads it, and the account row decides — not the // username claim in the token. No session is not a refusal: the route's own // requireAuth answers that with its usual 401. async function callerIsDemo(req) { const session = sessions().validateSession(req) return Boolean(session && (await isDemoUserId(session.userId))) } // `PUT /admin/settings` stays open (DM2), but that one route writes arbitrary // keys, and a few keys are another door into something Tier A locks: `site_mode` // is what `PUT /admin/site-mode` writes, `module_source_hosts` what the Modules // screen's sources do, `teams_voice_*` the voice settings. `demo_*` is the // banner and the published logins, which the seed owns. `hero_layout` is the // live homepage hero (DM21): the editor's draft (`hero_layout_draft`) and its // Preview stay open, its Publish is refused. const PROTECTED_SETTINGS = [/^site_mode/, /^module_source_hosts$/, /^teams_voice_/, /^demo_/, /^hero_layout$/] function protectedKeys(body) { if (!body || typeof body !== 'object' || Array.isArray(body)) return [] return Object.keys(body).filter((key) => PROTECTED_SETTINGS.some((re) => re.test(key))) } // ── Core's list (§3.3) ───────────────────────────────────────────────────── // Paths are relative to /api/v1 and written as the routers declare them; // test/demoLocks.test.js holds every row against routes.manifest.json, so a // row that stops naming a real route fails the build rather than reading as a lock. const CORE_LOCKS = [ // Tier A — site configuration. { methods: ['PUT'], path: '/admin/site-mode' }, { methods: WRITES, path: '/admin/modules/*' }, { methods: WRITES, path: '/admin/auth/providers/*' }, { methods: ['PUT'], path: '/admin/email/config' }, { methods: ['POST'], path: '/admin/email/test' }, { methods: ['POST'], path: '/admin/email/disconnect' }, { methods: ['PUT'], path: '/admin/discord-bot/config' }, { methods: WRITES, path: '/admin/teams/voice/*' }, { methods: ['PUT'], path: '/admin/settings', when: (req) => protectedKeys(req.body).length > 0 }, // Tier A — the shared logins, by an admin. { methods: ['PUT', 'DELETE'], path: '/admin/users/:id', when: targetIsDemo }, { methods: ['DELETE'], path: '/admin/users/:id/trusted-devices', when: targetIsDemo }, { methods: ['DELETE'], path: '/admin/users/:id/trusted-devices/:deviceId', when: targetIsDemo }, { methods: ['POST'], path: '/admin/users/:id/mfa/reset', when: targetIsDemo }, // Tier A — the shared logins, by whoever is signed in as one. { methods: ['PATCH'], path: '/auth/me/account/username', when: callerIsDemo }, { methods: ['PATCH'], path: '/auth/me/account/password', when: callerIsDemo }, { methods: ['PATCH'], path: '/auth/me/account/email', when: callerIsDemo }, { methods: ['POST'], path: '/auth/me/account/totp/setup', when: callerIsDemo }, { methods: ['POST'], path: '/auth/me/account/totp/enable', when: callerIsDemo }, { methods: ['POST'], path: '/auth/me/account/totp/disable', when: callerIsDemo }, { methods: ['POST'], path: '/auth/me/account/recovery-codes/generate', when: callerIsDemo }, { methods: ['DELETE'], path: '/auth/me/account/identities/:provider', when: callerIsDemo }, // ACCOUNT_DELETION.md §4.3: the screen stays visible on the demo, because // Play's reviewer must be able to find it, and the shared logins cannot be // deleted. An account an admin created on the demo can be. { methods: ['DELETE'], path: '/auth/me/account', when: callerIsDemo }, // DM17: the whole route, because a mobile access token carries no refresh-row // id and "a session that is not the caller's" cannot be decided. Signing out // (POST /auth/mobile/logout) revokes the caller's own row and stays open. { methods: ['DELETE'], path: '/auth/me/sessions/:id', when: callerIsDemo }, // Tier A — credentials for everyone. { methods: ['POST'], path: '/auth/password/forgot' }, { methods: ['POST'], path: '/auth/password/reset/:token' }, // DM16: settings are open, so `player_registration` can be switched on; the // route is what keeps registration closed (§4). { methods: ['POST'], path: '/auth/register' }, // Tier C's one exception: the contact form mails a person. { methods: ['POST'], path: '/public/contact' }, // Uploads. `refuseMultipart` already stops every one of these; they are // listed too so the table in §3.3 and this file say the same thing, and so a // non-multipart body sent to an upload route is refused the same way. { methods: ['POST'], path: '/admin/uploads' }, { methods: ['POST'], path: '/admin/posts/upload' }, { methods: ['POST'], path: '/admin/settings/brand-asset/:slot' }, { methods: ['POST'], path: '/player/teams/:slug/forum/uploads' }, ].map((rule) => ({ ...rule, source: 'core', compiled: compile(rule.path) })) /** * Build the lock middleware. * * @param {() => object[]} moduleLocks returns the installed modules' compiled * locks — asked per request, because the loader runs after app.js mounts this. */ function createLock(moduleLocks = () => []) { return async function lock(req, res, next) { if (!WRITES.includes(req.method)) return next() const rules = CORE_LOCKS.concat(moduleLocks()) for (const rule of rules) { if (!rule.methods.includes(req.method)) continue const params = match(rule.compiled, req.path) if (!params) continue if (rule.when) { let applies try { applies = await rule.when(req, params) } catch (err) { // Fail closed: a lock that cannot decide is a lock. log.warn('lock condition failed; refusing', { path: rule.path, message: err.message }) applies = true } if (!applies) continue } log.info('refused', { method: req.method, path: req.originalUrl, rule: rule.path, source: rule.source }) const keys = rule.path === '/admin/settings' ? { keys: protectedKeys(req.body) } : {} return refuse(res, keys) } return next() } } // Any multipart content type, not only form-data: nothing on the demo takes one, // and a narrower test is one more spelling to get wrong. The header alone // decides, so a multipart request with no body is refused too. function refuseMultipart(req, res, next) { const type = String(req.headers['content-type'] || '').trim().toLowerCase() if (!type.startsWith('multipart/')) return next() log.info('refused upload', { method: req.method, path: req.originalUrl }) return refuse(res) } // DM25: an image pasted into rich text travels inside the JSON body as a data: // URI, which is an upload by another name. Any string anywhere in a write's // body is checked, so a field added later, a setting or a module's route is // covered without being listed, as with multipart. // // A data: URI is `data:` (not the tail of a word like "metadata:") followed by // a comma, a `;`, or a type/subtype and then a comma. Prose such as // "Data: none, yet" does not match. Before testing, HTML character references // are decoded and tabs and newlines removed, because a browser does both when // it reads an attribute: `data:` and `data:` are still data: URIs. const DATA_URI = /(?"']{0,256},)/i function browserReading(s) { return s .replace(/&#x([0-9a-f]+);?/gi, (m, hex) => safeChar(parseInt(hex, 16), m)) .replace(/&#(\d+);?/g, (m, dec) => safeChar(parseInt(dec, 10), m)) .replace(/:/gi, ':') .replace(/&(?:tab|newline);/gi, '') .replace(/[\t\n\r]/g, '') } function safeChar(code, original) { return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : original } function hasDataUri(body) { const stack = [body] while (stack.length) { const value = stack.pop() if (typeof value === 'string') { if (value.length > 5 && DATA_URI.test(browserReading(value))) return true } else if (value && typeof value === 'object') { for (const v of Object.values(value)) stack.push(v) } } return false } function refuseDataUri(req, res, next) { if (!WRITES.includes(req.method) || !hasDataUri(req.body)) return next() log.info('refused data: URI', { method: req.method, path: req.originalUrl }) return refuse(res) } function noindex(req, res, next) { res.set('X-Robots-Tag', 'noindex, nofollow') next() } function robots(req, res) { res.type('text/plain').send('User-agent: *\nDisallow: /\n') } module.exports = { CODE, MESSAGE, WRITES, CORE_LOCKS, PROTECTED_SETTINGS, createLock, refuseMultipart, refuseDataUri, hasDataUri, noindex, robots, refuse, protectedKeys, }