Security model
What PolarBot protects, against what, and how. For the policy and how to report a problem see Security.
What is worth protecting
| Asset | Why it matters | Where it lives |
|---|---|---|
| The bot token | Whoever has it controls the bot in every server it is in | discord_token credential |
The OAuth client secret and oauth_key | Together they could forge /perms authorize links or read stored refresh tokens | discord_client_secret, oauth_key credentials |
| Stored refresh tokens | Let the holder change command permissions in a server | guild_oauth, encrypted |
| Punishment history | Personal data | The database |
| The bot’s Discord permissions | Ban, kick, timeout, delete messages | Discord |
Credentials
- Secrets are never in the config file: a key named
token,secret,passwordorapi_keyanywhere in it stops the start, and config error messages never quote the file’s contents (so a misplaced secret does not end up in the journal). - On Linux they are systemd credentials: encrypted at rest with the machine’s credential
key (
systemd-creds encrypt, using the TPM2 chip when there is one), stored as/etc/polarbot/*.credwith mode 0600, and decrypted by systemd only into the service’s private, in-memory$CREDENTIALS_DIRECTORYwhile it runs. Copying a.credfile to another machine does not reveal it. install.shreads them with hidden input and pipes them straight intosystemd-creds; they are never written to disk in plain text.oauth_keyis generated from/dev/urandom.- Credential names are restricted to
[a-z0-9_], so a name can never point outside the credentials directory. - Secrets are held in memory as
SecretStringand are never logged; errors never contain tokens, codes or keys.
OAuth (command visibility)
/perms authorize lets an Owner grant PolarBot the right to change the server’s command
permissions (scope applications.commands.permissions.update).
- PolarBot replies (privately) with a Discord authorization link. Its
stateparameter is<server>.<user>.<expiry>.<MAC>: an HMAC-SHA256 over those three values, valid for 10 minutes. The server is preselected and locked on Discord’s page. - Discord redirects the browser to
<public_url>/oauth/callback?code=…&state=…. - The callback verifies the MAC (constant time) and the expiry, and uses the state up: each state works once (used states are remembered until they expire; at most 10 000, beyond that new callbacks are refused rather than letting memory grow).
- It exchanges the code at Discord’s token endpoint (8 s timeout, the response capped at 64 KiB), checks that the required scope was granted, and stores the refresh token encrypted with AES-256-GCM: a fresh random 96-bit nonce for every encryption and the server ID as associated data, so a token only decrypts for the server it was issued for.
- Each sync refreshes the token (Discord returns a new one, which replaces the old) and sets the permission overrides. A token that Discord rejects (HTTP 401, or “Missing Access” / “Missing Permissions” because the authorizing admin lost their rights) is deleted; nothing new is exposed after that.
The HMAC key and the encryption key are two different subkeys derived from oauth_key with
HKDF-SHA256, so neither primitive ever sees the other’s key. A new oauth_key makes every
stored authorization unreadable: servers then run /perms authorize again.
Overrides only ever allow roles whose /perms level meets a command’s level; @everyone is
never added, and unknown commands get no override (hidden from all but Administrators).
Discord’s visibility is a convenience: the bot’s own level check decides on every use.
The web server and nginx
- PolarBot listens on
127.0.0.1:8787only (it warns at startup if[web] bindis not a loopback address) and serves onlyGET /oauth/callbackandGET /healthz. It runs only when both OAuth credentials are configured. - Every response has a 10 s deadline and the headers
Content-Security-Policy: default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none',X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrer(the callback URL carries the one-time code) andCache-Control: no-store. No CORS headers. - Pages show only fixed texts; nothing from the request is reflected. Codes, states and tokens are never logged.
- nginx terminates TLS (TLS 1.2 and 1.3, HSTS) and forwards exactly those two paths,
GETonly; everything else is a 404 from nginx and never reaches the bot. Request bodies are limited to 1 KiB and slow clients are cut off after 10 s. The access log is off and the error log keeps only critical errors, because the callback URL carries the one-time code.
The systemd sandbox
deploy/polarbot.service runs the bot with as little as possible:
| Setting | Effect |
|---|---|
DynamicUser=yes | A throw-away system user that exists only while the service runs; owns nothing else on the machine |
StateDirectory=polarbot, StateDirectoryMode=0700, UMask=0077 | The only writable place is the data directory, readable only by the service |
ProtectSystem=strict, ProtectHome=yes, PrivateTmp=yes, PrivateDevices=yes | The rest of the file system is read-only; no home directories, no shared /tmp, no devices |
NoNewPrivileges=yes, CapabilityBoundingSet= (empty), AmbientCapabilities= (empty) | No way to gain privileges or capabilities |
PrivateUsers=yes, ProtectProc=invisible, ProcSubset=pid | Other users and processes are invisible |
ProtectKernelTunables, ProtectKernelModules, ProtectKernelLogs, ProtectControlGroups, ProtectClock, ProtectHostname | No changes to the kernel, clock or host name |
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX, RestrictNamespaces, RestrictRealtime, RestrictSUIDSGID, LockPersonality, MemoryDenyWriteExecute, RemoveIPC | Only IP and local sockets; no namespaces, realtime scheduling, set-uid files or writable+executable memory |
SystemCallArchitectures=native, SystemCallFilter=@system-service, SystemCallFilter=~@privileged @resources | Only the system calls an ordinary service needs |
MemoryMax=1G, TasksMax=512, LimitNOFILE=8192 | A runaway bot cannot take the machine down |
RestartPreventExitStatus=78, StartLimitBurst=5 in 600 s | No restart loop on a configuration problem |
You can check the result with systemd-analyze security polarbot.
Untrusted input
- No user URL is ever fetched. Links are judged by their text. The only outbound connections are Discord, Discord’s OAuth token endpoint and the configured domain lists.
- Domain lists are downloaded over https only (redirects too, at most 5), with a size cap
(
max_list_bytes) and a 60 s timeout. A download that does not parse to at least one domain is ignored. List names are restricted to[a-z0-9_-]because they become file names. - Messages and names shown on cards and in logs are escaped (backticks and
@are defused) and every reply is sent with mentions disabled; only the configured mod role may be pinged, and only for punishments. - Card buttons are checked on every press: the card must be the bot’s own message in the same server, the presser must be a Moderator (read fresh from the database), and a card is handled once.
- Moderation checks Discord’s role order and PolarBot’s levels for the moderator, the target and the bot itself; when role data is stale or unknown, it refuses instead of guessing. Unknown commands and subcommands need the highest level.
The database and backups
The SQLite database sits in the service’s private data directory (mode 0700). install.sh
backs it up to /var/backups/polarbot (directory 0700, files 0600) before every install.
Refresh tokens in it are encrypted; everything else is plain, so protect backups like the
server itself.