Security baseline
The following measures are on by default, and each one has matching tests or automated checks.
Authentication and sessions
- No JWT. Access tokens and refresh tokens are both random strings, and Redis stores only their hashes;
- Access tokens last 30 minutes by default and renew automatically while you are active, but never beyond an absolute cap (12 hours; 7 days when Keep me signed in is ticked or when signing in on mobile. Both caps are fixed and cannot be configured);
- Refresh tokens are stored in an
HttpOnly+SameSite=Strictcookie and are replaced after every use. If an old token is reused, the whole session chain is revoked; - When an administrator resets a password or disables or deletes a user, and when a password is reset through an SMS code, all of that user's sessions end. When users change their own password, all their other sessions end and only the current one stays. Force sign-out ends only the session it targets (End all sessions ends all sessions of that user). The WebSocket connections of ended sessions are also closed at once;
- When users change their own password, reset it through an SMS code, or have it reset by an administrator, the account's WeChat mini program link is also removed. So even if someone once used a leaked password to link their own WeChat account, they can no longer sign in after the password change. Force sign-out does not unlink.
Sign-in protection
- Rate limits: counted separately by IP and by username. The per-IP counters are stored in Redis, so multiple service instances share the same quota. If Redis has a temporary error, requests fail outright (500) instead of being let through (see Deployment · Multi-instance deployment (Chinese));
- Five failures in a row for the same username + IP pair lock that pair for 10 minutes. Only that pair is locked, so nobody can use this to lock out the administrator's account;
- When one username has too many failures in total across several IPs, a captcha becomes mandatory;
- Usernames that do not exist are counted too, and their responses match those of existing usernames in both content and timing, so this cannot be used to probe whether an account exists;
- Supported as well: an IP blocklist (single addresses or CIDR ranges;
*wildcards are not supported, and malformed entries are ignored), mandatory password change at the first sign-in, password expiry, and a password complexity policy (the frontend and backend use the same rules).
WeChat mini program sign-in
WeChat mini programs are lightweight apps that run inside WeChat, the messaging app widely used in China. For the feature and how to enable it, see Sign-in and accounts · WeChat mini program sign-in (Chinese). Only the security rules are listed here:
- Off by default. If the switch
auth.wx_mp.enabledis off, orWX_MP_APPID/WX_MP_SECRETis missing, the sign-in and link endpoints return 404; - The
codesent by the mini program can be used only once. Thesession_keyreturned by WeChat is discarded as soon as the server receives it: it is neither stored nor sent to the client; - A WeChat account that is not linked yet gets a link ticket: valid for 5 minutes, usable only once, and only from the same IP;
- Linking requires proving your identity with the account password or an SMS code, under the same captcha, lockout and rate-limit rules as normal sign-in. The same WeChat account cannot be linked twice (409), and an account can be linked to only one WeChat account;
- Unlinking works even when the switch is off, and it ends the user's sessions on other phones (the current one stays);
- Every WeChat sign-in is recorded in the sign-in log with the type
wx-mp. When identity verification passes but linking is refused, a failed entry is recorded as well.
Single sign-on (OAuth2)
For the feature, see Single sign-on (OAuth2); for integration details, see the OAuth2 integration guide (Chinese). Only the security rules are listed here:
- The authorization code flow requires PKCE, and only
S256is accepted;plainor no method is rejected. Authorization codes are valid for 300 seconds and usable only once; a code whose token exchange failed is voided as well; - Confidential clients only: requesting, introspecting and revoking tokens all require the client secret, even with PKCE;
- Client secrets are generated by the server (256-bit random values) and shown only once, at creation and on reset. The database stores only their SHA-256 digest (this is not encryption, and it cannot be reversed), and the plain text never appears on any page, API or log afterwards. The comparison uses a constant-time algorithm and runs even for clients that do not exist, so response times cannot reveal whether a client exists;
- Redirect URIs accept only
https://at registration (excepthttp://localhostandhttp://127.0.0.1for local debugging), with no#, wildcards or user name and password, and they are compared character by character during authorization. For an invalid request, the consent page shows the reason in place and never redirects, so users are never sent to an unregistered address. The consent page also shows the host name it will redirect to, helping users spot apps pretending to be someone else; - Third-party tokens are isolated from the admin console: third-party tokens can only call the user info endpoint. Any other admin API returns 401, and they cannot connect to realtime push either. The admin console's refresh endpoint does not accept third-party refresh tokens. The authorization endpoint only accepts this system's own sign-in sessions, so a third party cannot click Allow on the user's behalf.
consoleandmobileare reserved client IDs and cannot be registered; - Sessions end with the account: when a user changes their password, has it reset, is disabled, is deleted, or is signed out with End all sessions, all of their OAuth sessions and unredeemed authorization codes stop working together. Disabling or deleting a client ends all its sessions at once. Refresh tokens are replaced after every use; if an old token is reused outside a 30-second grace period, the whole session is revoked;
- These tokens are also random strings, not JWTs, and Redis stores only their hashes. The token endpoint is rate-limited by IP. Secrets, authorization codes and tokens in request logs are masked automatically, and when consents are written to the action log, redirect URLs that carry an authorization code are masked too.
Broken access control (IDOR)
- Every endpoint that reads, edits or deletes by id first checks whether the record is within the current user's data scope; out-of-scope records return 404;
- Privilege escalation guard: you cannot give others more permissions than you have. See Permissions and data scope;
- Attachments in process forms are private files. Apart from the uploader, the super administrator and people with the View permission of the file list (
storage.object.view), only people who can view the process instance and can see the attachment field can download them; see Workflow · Attachments. - Approval data is filtered by field access: only the super administrator and the process managers of that process see all fields. Others do not see fields that were hidden at any step of the process (not on the page, in exports or in filters). Because process managers can see all fields, changing the list of process managers needs the separate permission
wf.model.managers; people with onlywf.model.modifycannot add themselves (403). See Workflow · Field access in approval data.
Injection and code execution
- Only parameterized SQL is used, and sort fields are checked against an allowlist. A check script forbids concatenating strings into SQL;
eval,new Functionandvmare banned (checked by lint and by scripts);- Scheduled tasks can only call handlers on an allowlist, and process conditions can only use structured rules;
- Form schemas saved by the form designer are rendered in other people's browsers, so on save the frontend and backend check them against the same allowlist. Functions, strings that would run as code (such as
$FN:), event and linkage settings, and unknown components are rejected outright with 400; settings outside the allowlist are stripped, and the server stores only the filtered result. See Form designer · Security; - BPMN diagrams are treated as data, never as code. The server accepts only an allowlisted subset (start, end, approval, CC, exclusive/parallel/inclusive gateways and sequence flows); script tasks, expressions (
${...}), listeners and the extension settings of other BPMN tools are all rejected. Conditions can only be set with the condition builder and are never evaluated as expressions. XML over 80 KiB, or with a DOCTYPE or entity declarations, is rejected before parsing, and parsing never touches the network or files. On publish, the server derives the process tree again from the XML, checks it the same way as a process drawn in the tree designer, and runs it on the same tree-based engine; it never trusts results sent by the browser. See Workflow; - After the build,
pnpm ci:localscans the frontend output to make sure it contains noeval/Functioncalls outside the allowlist, and no wangeditor v4 code.
XSS
- Rich text is sanitized against an allowlist when it is saved (removing
style,on*,scriptandiframe); - Inbox messages are handled as plain text; mail previews are shown in a sandboxed iframe;
- Frontend pages carry a strict Content Security Policy (CSP).
File uploads
- The real type is detected from the file content, never trusting the extension. An extension allowlist is used, and
html,svgandjscan never be uploaded; - Files are saved under random names to prevent path traversal;
- Downloads force
attachment+nosniff+CSP sandbox; - Private files can only be downloaded through an authenticated endpoint.
SSRF
For external addresses that administrators can configure (S3, SMTP), the server resolves DNS before connecting, rejects private network, loopback and cloud metadata service addresses, and allows only ports on an allowlist.
When editing an S3 storage config, if you change any of Endpoint, Bucket or Access key ID, you must enter the Secret access key again; otherwise you get 422 and none of the change is saved. This way, someone who can edit storage configs cannot carry a saved key over to another address.
Other
- Excel: on export, cells starting with
=,+,-or@are escaped to prevent formula injection. On import, file size and row count are limited, and files containing macros are rejected; - Duplicate submits: create-type endpoints guard against duplicate submits on the server; a repeated request within 3 seconds returns 429 (for how to write it, see Duplicate-submit guards, rate limits and locks (Chinese));
- Sensitive data: passwords are stored with bcrypt. Secrets of third-party services (such as S3 and SMTP) are encrypted with AES-256-GCM before being stored in the database. OAuth2 client secrets are stored only as SHA-256 digests. Fields such as passwords and tokens in logs are masked automatically;
- Production secret check: with
NODE_ENV=production, ifAPP_SECRETorSEED_ADMIN_PASSWORDcontainsnot-for-production(case-insensitive), the service refuses to start, andpnpm db:migrateandpnpm db:seedalso exit before connecting to the database. Every test password committed to the repository carries this marker, so none of them can slip into production by mistake. The check only looks for this marker and does not judge password strength; you still need to generate random values for production secrets yourself; - Demo mode: with
APP_DEMO_MODE=true, all write operations return 403, for the super administrator too, except for a few such as signing in, signing out, unlocking the lock screen, saving personal preferences and marking as read. IPs, locations, browsers and similar details in online users and the various logs are masked both on the page and in exports (the original records in the database are unchanged). See Deployment · Demo mode (Chinese); - Supply chain: dependency versions are locked; a new version can be installed only once it has been released for 24 hours; the install scripts of dependencies must be approved one by one; dependency licenses are checked automatically.