SWIFTPOINT//HUB
Support

Security and privacy — the defences and the promises

This file covers the security controls of the hub: the ContentSecurityPolicy and the other response headers, how usersupplied markdown and uploaded files are ha

This file covers the security controls of the hub: the Content-Security-Policy and the other response headers, how user-supplied markdown and uploaded files are handled, the path safety of the guides writer, and the two legal pages — which are generated in code precisely so that their claims can be checked against the implementation.

Threat model in one paragraph

Four kinds of person reach this site: an unauthenticated reader, a signed-in member who can post text, a moderator, and an admin. From those four, five risks follow, and each has one control. A member injecting script — the CSP plus html: false in the markdown renderer, so raw HTML is never parsed at all rather than being filtered. A member reading a local file — the URL allowlist refuses file:, blob: and data: for both links and images, and a post cannot emit an <iframe> because it cannot emit a tag. A member turning a post into a phishing form — the same html: false rule, since a <form> is markup, plus form-action 'self' in the CSP as the second layer. One member learning another member's Discord id — the public API carries no Discord id at all, and ownership questions are answered server-side. A guide author breaking the site — every route of /api/admin/docs is requireAdmin, and absFor() refuses any path that would leave docs/. What is out of scope: a compromised server, a stolen SESSION_SECRET, and denial of service beyond the size and rate limits below.

Content-Security-Policy

One header, set in server.js for every response, built from this exact list:

Directive Value Why
default-src 'self' Everything unlisted comes from this origin.
script-src 'self' There is not one inline script in the whole product — every page is a static HTML file with <script src="…"> tags, and every behaviour is attached to markup that is already there. That is what makes this possible: an injected <script> or an onclick= attribute has nowhere to come from and would not run if it did.
style-src 'self' 'unsafe-inline' https://fonts.googleapis The stylesheet is self-hosted; 'unsafe-inline' is there because the OLED editor and the recorder draw into inline style attributes, and the font is loaded from Google's CSS host.
font-src 'self' https://fonts.gstatic.com Where the webfont files above come from.
img-src 'self' data: blob: https: http: data: and blob: are needed by the OLED editor's canvas exports and by recorder screenshots; https:/http: allow an image in a post or a guide to point anywhere.
media-src 'self' https: Audio and video the site serves itself.
frame-src 'self' https://www.youtube-nocookie.com The only third party this site may load from, for ::: video. It is named rather than derived: without an explicit frame-src the whole directive falls back to default-src 'self', and every video renders as a blank box — which looks like broken markdown rather than like a policy. One host, no wildcards, because an embed feature that could frame anything is not an embed feature.
connect-src 'self' fetch and EventSource may only talk to this origin.
object-src 'none' No <object>, ever.
base-uri 'self' A post cannot move the base URL and re-point every relative link.
frame-ancestors 'none' Nobody may frame the site, which is the modern half of clickjacking defence.
form-action 'self' Even if a form could be injected, it could not post anywhere off-site.

youtube-nocookie.com rather than youtube.com is a free choice: it sets no cookie until somebody presses play, so a reader who scrolls past a video in a thirty-post thread has not been tracked. test/blocks.test.js asserts that the frame-src line names that host and contains no *.

The other headers

X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Strict-Transport-Security: max-age=31536000        (HTTPS responses only)
Header Why
X-Content-Type-Options: nosniff The site serves a .spcf as application/octet-stream. Without nosniff a browser is entitled to ignore that and render the bytes as HTML, which for a file anybody can upload is a stored-XSS hole with an attachment header in front of it.
X-Frame-Options: DENY The legacy clickjacking defence, kept next to frame-ancestors 'none' because not every browser or embedder honours the newer one.
Referrer-Policy: strict-origin-when-cross-origin A cross-origin request sends the origin, not the path, so a guide link to somebody else's site does not leak which guide you were reading. Same-origin requests keep the full referrer, which is what relative links need.
Strict-Transport-Security Sent only on HTTPS responses (req.secure or x-forwarded-proto: https), so a plain-HTTP health check is not answered with a header the browser will obey for a year.

The HSTS value is max-age=31536000 — one year, the floor every modern browser accepts without a preload — and it is deliberately sent without includeSubDomains. mecke.dev serves *.mecke.dev, and a subdomain that is ever served without TLS becomes unreachable for as long as the browser remembers the header: the redirect to https:// is itself what fails. One year is a promise this app can keep about its own hostname; includeSubDomains is a promise about infrastructure this app does not own.

Cache policy sits next to these headers and is deliberately conservative: css, js, fonts and images get public, max-age=86400, stale-while-revalidate=604800; .spcf, .json and .rec.json get one hour with must-revalidate; everything else — html included — gets no-cache. The asset files are served under their own names and are not fingerprinted, so nothing is served immutable under a name that can change. Full detail in Configuration and deployment.

Markdown is escaped, not filtered

lib/markdown.js is the whole security argument for user-supplied text, and the idea in it is worth stating plainly.

html: false. Raw HTML is never parsed. A post cannot carry a <script>, a style tag, an <iframe>, an event handler or a <form> — not because a filter caught them, but because markdown-it was never asked to recognise them. There is no tag list to keep up to date, so there is no way for one to be forgotten, and there is no path by which a tag the author of the filter had not thought of gets through. Everything else in the module only narrows what markdown-it is willing to emit; this is what stops it parsing anything that is not markdown.

The URL allowlist. safeUrl() accepts a site-relative path (/… but not //…), a fragment (#…), or http(s):// with a host. Everything else is null:

Refused For links For images
javascript: yes yes
data: yes yes, deliberately
vbscript: yes yes
blob: yes yes
file: yes yes
mailto:, tel:, ftp: yes yes
protocol-relative //host yes yes

data: is refused for images specifically because an inline data:image/svg+xml is a script vector in an <img> on some browsers, and "a post should not be able to run code" should not depend on which browser you are reading in.

What happens to a refused value differs by element, on purpose:

  • A refused link emits no tag at all. Emitting <a> with no href would still be a focusable, clickable-looking thing.
  • A refused image shows its alt text, inside a <span class="fr-img-bad"> with the title Only http:// and https:// image links are shown. A post that quietly loses its picture is a bug nobody can file.

Links. An external link gets target="_blank" and rel="noopener noreferrer nofollow ugc": it cannot reach back through window.opener, it does not leak the address bar, and it does not pass link equity or an author claim to somebody else's site. An internal link gets rel="noopener".

Images. The output is exactly:

<img class="fr-img" src="…" alt="…" loading="lazy" referrerpolicy="no-referrer" />

No size, no class from the author, no onerror. An <img> with only src, alt, loading and referrerpolicy cannot do anything but show a picture.

The class prefix is a parameter, not a constant. createRenderer({ prefix }) — the guides pass d- and the forum passes fr-, and the video/changelog/spoiler markup follows it. That is what stops the two surfaces from changing each other's layout by writing a heading: a forum post cannot produce a d-video class, and a guide cannot produce an fr-entry badge.

renderForumPost(source, env) takes env.changelog, and that decision is made here rather than anywhere else — see the next section.

The changelog badge gate

The five ::: changelog blocks (added, changed, fixed, removed, security) render as an entry with a coloured badge. security in particular is the one badge on this site that reads as the people who run this are telling you to act.

Hiding the five buttons from the forum toolbar stops nobody: markdown is plain text, so anybody can type ::: security and get the red badge. The decision is therefore made at render time, inside the renderer, from the author's own standing:

const allowed = !env || env.changelog !== false;

Why there and not in the toolbar, or in the route, or in the database:

  • the toolbar is a client control and the client is not trusted;
  • the route would have to decide per request and then tell the renderer, which is the same decision in a place with less context;
  • a stored is_staff flag on the post would mean promoting somebody does not change how their old posts render until they are rewritten.

Deciding it in the renderer settles the question once: nothing in the database is rewritten, and promoting somebody starts rendering their old posts correctly. The caller supplies the flag — renderForumPost(body, { changelog: isStaff(req) }) for a thread, and the same for the preview endpoint, so a member who typed the block by hand sees their paragraph rather than a badge that would disappear the moment they press Post. When the badge is refused the fence is still consumed and the words inside still show; what the member does not get is the badge. test/forum.test.js covers all of it.

Upload validation

A .spcf is not trusted because it is small. lib/spcfParser.js refuses it in stages, and every message is a 400 with the parser's own text:

Check Where Refusal
size routes/profiles.js, multer limits.fileSize 2 MB — File too large. Maximum size is 2 MB.
a byte-order mark parseSpcf() stripped, not refused
a first line at all parseSpcf() File has no recognizable format (no header found).
the header line `/^json([,\s] $)/` on the trimmed header
a NUL byte in the body parseSpcf() File appears to be binary — a .spcf file must be text.
valid JSON JSON.parse Could not parse the JSON content of the .spcf file: …
root is an object validateRoot() Profile content is not a JSON object.
RootProfile present validateRoot() File does not contain a valid "RootProfile".
Groups a non-empty array validateRoot() Profile contains no groups.
groups capped MAX_GROUPS = 100 Profile has too many groups.
mappings capped MAX_MAPPINGS = 1000 Profile has too many mappings.
depth capped MAX_NESTING = 20 Profile groups are nested too deeply.
a malformed group or ActionMappings validateRoot() Malformed group entry in profile. / Malformed ActionMappings in profile.
per-user volume MAX_PROFILES_PER_USER, default 3, overridable by the max_profiles_per_user setting 429, You can upload at most <n> profiles. Delete one first to make room. (admins are exempt)

The caps are before any recursion, so a crafted file cannot exhaust the stack, and the 2 MB body limit is far above anything the format needs. The NUL check is the one that matters most in practice: it refuses a renamed binary before anybody tries to JSON.parse it.

User-entered fields are cleaned, not trusted. cleanField(value, max, fallback) and cleanFieldMultiline() strip control characters, replace <…> with a space, remove any remaining angle brackets, collapse whitespace and cut to the length. The same rule is used everywhere a person types:

Field Limit Fallback
title 80 refused if empty — Title missing.
authorName 60 Anonym
description 400 null
sourceFilename 255 upload.spcf
comment body 1000 refused if empty
game name 80 (MAX_GAME in lib/games.js) canonicalised against the taxonomy
report reason 500 refused if empty
forum title / post body 120 / 8000 refused if empty

cleanGame() strips control characters, replaces <...> with a space, collapses whitespace and cuts at 80 characters, and the identical cleaning is applied to titles, authors and descriptions, so nothing surprising reaches the taxonomy or the page. Nothing here is a substitute for html: false: the cleaning makes the stored value tidy, and the renderer is what makes it safe.

Filesystem path safety in the guides writer

The guides editor writes real files under docs/, so it is the one admin surface that touches the filesystem. lib/docs/write.js absFor(rel) refuses in four ways before anything is opened:

Refused Why
a \0 byte —
any backslash Windows separators, which path.resolve on Linux would treat as a filename character
a leading / an absolute path never reaches path.resolve with a meaning of its own
any segment that is .., ., or whitespace-only ..%2f and a/../../b cannot climb

and then, as the backstop for anything the first three miss, it verifies that the resolved path is inside the root:

const root = path.resolve(DOCS_ROOT);
const abs = path.resolve(root, parts.join('/'));
if (abs !== root && !abs.startsWith(root + path.sep)) return null;

A folder can never have the same name as a guide: resolveSlug finds the .md first, so the folder would exist and be unreachable. createFolder() refuses it —

There is already a guide at <path>. A folder cannot have the same name.

Writes are atomic: a temp file beside the target, then fs.renameSync. A half-written guide is never what the renderer reads, and a failed write leaves the old guide intact. Names are slugified, so a folder is always a URL segment.

Deleting a folder is two-step. The first call refuses with 409 and says what is in it:

{ "error": "That folder is not empty.", "needsConfirm": true, "files": 12, "folders": 3, "countsGuides": 12 }

and only ?recursive=true deletes it. A folder here holds recordings and screenshots, so the answer to "are you sure" has to be the number of files, not a yes/no the reader cannot know.

The root itself can be deleted or renamed from here: That folder cannot be deleted from here. / That folder cannot be renamed.

test/docs-write.test.js asserts that nothing escapes the root, including through a traversal hidden in a name.

Uploads are stored byte-for-byte

Both halves of the file are kept separately: format_header is the first line, raw_json is everything after it. The download route is a concatenation, not a re-serialisation:

db.prepare('UPDATE profiles SET downloads = downloads + 1 WHERE id = ?').run(row.id);
const body = `${row.format_header}\n${row.raw_json}`;
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${...}.spcf"`);

The filename is the profile title with everything outside [a-z0-9_-] replaced by _, so a title cannot inject a quote or a path into the header. nosniff plus application/octet-stream plus attachment is what keeps the file a download rather than something a browser renders. test/http-headers.test.js asserts that a .spcf download is still served byte-for-byte and that the hardening headers are intact on that response.

Covered in full in Auth and roles. The short form:

Control Value
provider Discord OAuth2 only
scope identify — no email, no guilds
password none exists
cookie swiftpoint.sid, HttpOnly, SameSite=Lax, 12 h
state parameter 24 random bytes, stored in the session, compared and cleared on the callback
returnTo safeReturnTo(): rooted, not //, no backslash, no control characters, else the site root
session id regenerated on login, so a pre-login id cannot be reused
roles recomputed from the allowlist on every request, never read from the session snapshot

Abuse controls

Control Where Behaviour
Reporting POST /api/reports, requireUser one open report per user per target; a duplicate is 409 You already reported this.
Post reports routes/reports.js carry threadId and postId, and the queue links straight at the reply, because "there is a report" with no way to act on it is the same as not being told
Rate limiting and moderation requireModerator on pin, lock, move, delete, status, categories, updates see the permission matrix
Upload cap routes/profiles.js 429 over the per-user limit; admins exempt
Mark-notifications-read cap routes/notifications.js ids are de-duplicated, filtered to integers and slice(0, 100)
Notification list routes/notifications.js LIST_LIMIT = 50
Forum search lib/forum.js cleanText(q, 80, '') — 80 characters, 20 threads and 20 posts per answer
Post length lib/forum.js MAX_BODY = 8000, MAX_TITLE = 120
Guide body length lib/docs/write.js MAX_BODY = 200000
Request body server.js 64 MB for the JSON parser, because a recording carries per-step screenshots as data URLs

The notification-read cap matters more than it looks: an unbounded WHERE id IN (…) is a way to turn one request into a statement with ten thousand parameters.

Analytics

Only page views are tracked. There is no event tracking, no click tracking, no session recording, no third-party analytics script, and nothing in the CSP that would permit one.

const SALT = process.env.SESSION_SECRET || 'swiftpoint-hub-analytics-salt';
INSERT INTO pageviews (visitor_hash, path) VALUES (?, ?)
    visitor_hash = HMAC-SHA256(ip, SALT)
    path         = String(path).slice(0, 255)
Property Value
What is stored a path and a keyed hash — nothing else
The IP `HMAC-SHA256(ip, SESSION_SECRET
The path truncated to 255 characters, so a hostile URL cannot grow the table
trafficOverview() h1, h24, d7, d30, each { views, unique }, unique being COUNT(DISTINCT visitor_hash)
Pruning rows older than 30 days are deleted on every 200th insert
Admin traffic skipped on the static and tool pages (/, /oled, /check, /editor, /forum, /forum/t/:id, /updates), and on a profile page the admin view is excluded from the views counter too

The hash exists because unique visitors are the number worth having and the raw address is not. It is not a pseudonym for a person either: it is a per-install digest used only to count distinct hashes inside a time window.

See SEO and analytics for the meta tags and the sitemap.

The privacy page, in full

/privacy is rendered by lib/legal.js privacyHtml(). The design fact comes first: both legal pages are generated in code, not written as HTML, so they cannot drift from the implementation — every sentence is checkable against the file it describes, and test/legal-pages.test.js fails when a claim stops matching. The page is server-rendered, carries the site header and footer, and works without JavaScript.

The page is built from a FACTS object:

Fact Value
oauthProvider Discord
oauthScope identify
sessionCookie swiftpoint.sid
sessionHours 12
pageviews hashed client IP (HMAC) with the page path

and its sections, in order:

Signing in. The only way to have an account is Discord OAuth, asking for exactly the identify scope, which gives the account id, display name and avatar URL. There is no password, and the site never sees an email address or anything else about the Discord account. Signing in sets one cookie, swiftpoint.sid, for 12 hours; it is HttpOnly and SameSite=Lax, and it is the only cookie the site sets.

What is stored. The seven tables the site actually writes to, each described in the page's own words:

Table What the page says is in it
users your Discord account id, display name, avatar URL, and the day you joined
profiles the .spcf you uploaded, its title, game, description and tags
comments what you wrote on a profile, and when
votes which profile you up- or down-voted, and when
reports what you reported, and the status the moderation queue put it in
notifications what happened while you were away: replies, votes on your profiles, admin messages
pageviews a hashed client IP and the page path, for traffic statistics

Then the paragraph that matters: everything above lives in a SQLite database on the server, the public API does not publish anybody's Discord account id, ownership questions are answered by the server so a visitor can read a page without learning who is behind it, and avatars are Discord CDN URLs and public by nature.

What never leaves your device.

  • the .spcf Checker reads the file in your browser and uploads nothing
  • the Profile Builder keeps your profile in your own browser, and on the site only if you publish it
  • the OLED Editor draws to a canvas in your browser
  • an uploaded .spcf is stored byte for byte and downloads identical to the original

followed by: publishing is the one action that sends anything, and it only happens when you press publish. Replaying a recorded guide downloads a guide; it does not upload your profile.

What is stored in your browser. Five localStorage keys, all of them client-side and removable by clearing site data:

Key What it holds
sph-theme which colour scheme you picked
swh-docs-fold which guide sections you collapsed
sph:editor:pane-w, sph:editor:rail-w, sph:editor:rail-collapsed the widths you dragged the Builder panels to
sph:editor:draft the profile you are working on in the Builder, kept in this browser so a reload or a sign-in does not lose it, and replaced by the next one you open or by a new blank profile

Nothing on the server depends on any of it.

Analytics. Page views are counted with a hashed client IP and the page path. The IP is never stored in the clear: only a keyed hash of it, which cannot be turned back into an address, is kept alongside the path that was visited. The hash counts unique visitors and cannot identify you across sites.

How long it is kept. Page-view statistics are deleted automatically after 30 days. Everything else — your account, your profiles, your comments, your votes — is kept until you ask for it to be removed.

Your choices. Ask for a copy of your data, or for your account and everything attached to it to be deleted; both are handled by the same person who runs the site and both need a way to tell it is really you, which is what signing in with Discord is for. You can also delete a profile you uploaded yourself, and edit or delete any comment you wrote, from the page it is on — both are gone from the public site immediately.

Contact. Questions, requests and complaints go to:

the operator of this site (see the repository linked in the header)

and the page says plainly that this is a volunteer-run community project with no support desk and no response time to promise. Inventing one would be worse than not having it.

The imprint page

/imprint is imprintHtml(), same generated-in-code discipline:

  • Who runs this — an unofficial, non-commercial community project; not operated by, sponsored by or affiliated with Swiftpoint Ltd., and nothing there should be read as an official Swiftpoint service.
  • What is missing, and why — a registered company name, a postal address and a commercial-register entry are not published because the site has none of them. The page's own reasoning: inventing them would be worse than omitting them, and a visitor who needs a legal counterparty should not be given a fiction. If the site ever grows one, that is where it goes.
  • How to reach a person — the contact route on the privacy page; the repository linked in the header is where the operator is reachable, and issues filed there are answered in the open.
  • Trademarks — Swiftpoint, the Z3 and related names and marks belong to their owner; the site refers to them only to say which device its content is about and claims no rights in them.
  • Liability — profiles, guides and images are contributed by their authors and provided as they are. A profile that binds a button differently on your mouse is a fact about profiles, not a defect here. Nothing on the site is a warranty and nothing on it is professional advice.

What is deliberately not on the page: legal boilerplate this project cannot stand behind. Whether an imprint is required, and what it has to contain, depends on the operator's jurisdiction, so the page says what is known and names what is not. test/legal-pages.test.js fails if either page invents a legal claim.

How this is tested

File What it protects
test/http-headers.test.js HSTS is sent on HTTPS and only on HTTPS; HSTS does not promise subdomains; the other hardening headers are untouched; no asset is served as immutable under a name that can change; css, js and images are cached with a revalidation window; html revalidates and content does not outlive its owner; a route that sets its own cache header is left alone; a .spcf download is still served byte-for-byte as an attachment.
test/forum.test.js Markdown is rendered on the server and cannot smuggle anything; the preview is the same renderer the post will be; only staff can wear a changelog badge; the preview promises exactly what the post will be; changelog entries are siblings, not nested, however they are written.
test/blocks.test.js A video renders a player on both engines and each engine uses its own class prefix; every way of writing a YouTube link resolves to the same video and a link from anywhere but YouTube is refused; a chapter label cannot inject markup; the CSP names the one host a video may come from, and no wildcard; a dangling spoiler is closed on both engines.
test/docs-write.test.js Nothing escapes the guides root; a name is slugified so a folder is always a URL segment; the tree is the shape of what is on disk; creating never overwrites something already there; the front matter survives a round trip; a save that changes nothing says so and does not write; the body is capped; renaming moves the file and the folder moves everything inside it; deleting a non-empty folder asks first and reports what is in it; the root cannot be deleted or renamed; deleting a guide removes only that guide; the renderer reads a guide written there.
test/legal-pages.test.js Both pages are routed, titled and canonical and carry the header and footer without JavaScript; every table the page lists is a table that exists; the OAuth claim is the scope the code asks for; the cookie claim matches the session configuration; the retention claim matches what is actually pruned; every storage key the page lists is one the client writes; the local-first promise is stated because the code keeps it; the analytics claim matches the implementation; nothing in either page invents a legal claim; the public API claim holds; the deletion claim matches what the UI offers.
test/public-api-privacy.test.js The gallery and profile responses carry no Discord id; the uploader block is name, avatar and nothing else; ownership is answered, not computed in the browser; no client compares two Discord ids any more; the features that used the id still work from the server answer; the admin panel keeps the identifiers it works with; the session still tells a user their own id.
test/dom-contract.test.js Every id a page's script asks for exists in the markup; a page script cannot abort the rest of the page on one null element; the vote box is wired to the ids the markup has; no page links to a layout that no longer exists.
test/admin-access.test.js Every admin API route is behind requireAdmin; the admin pages refuse before sending any HTML; the admin surface is not reachable through the forum.
test/limits.test.js The editors and the server agree on 32 000 characters where they must; no editor promises more than the server keeps; the editor reads its budget per instance; the preview guard is at least as large as any surface it serves.

Next: SEO and analytics · Auth and roles · Data model


Back to the documentation index.