1. Validate the URL scheme before any redirect
Before using a URL from a database field, a request parameter, or an external API response in a redirect or an HTML page:javascript: URI from the database is stored XSS. An http:// URL leaks whatever
travels with it over plaintext. Only https:// is acceptable.
2. No sensitive values in URLs or logged headers
Tokens, session identifiers and credentials travel in the response body — or as HTML page content for a browser-side redirect — never in aLocation: header or a
query string.
Location: https://example.com?token=secret hands the token to every proxy, CDN and
access log in the chain (CWE-598).
3. No plaintext credentials or tokens
Any field storing a credential, session token or API key is encrypted at the application layer using the Key Vault utilities insrc/lib/crypto/. A raw String?
holding a token is a defect unless it is explicitly approved and documented as a known
limitation with a linked follow-up ticket. A CI guard checks this.
4. Sanitise before interpolation
Never interpolate user- or environment-supplied strings into:5. Constant-time comparison for tokens
=== for a token, secret or magic link. === short-circuits on the first
differing byte, which is a timing oracle.
6. Never trust an external API response
Validate the shape with Zod before persisting anything from SECO, bexio, Microsoft Graph or any other upstream. An unexpected field from someone else’s API is not a reason to skip validation — it is the reason for it.7. Ownership on every verb
For every route taking a resource ID, the ownership check applies to all handlers in the file —GET, PATCH, DELETE, POST — not just the one you are editing.
8. getCurrentLegalEntityId() is read-scope only
Every write persists an explicit, tenant-validated legalEntityId via
resolveWriteLegalEntity. The ambient resolver is on a path to becoming cookie-driven;
stamping a write with its value would let a user-controlled cookie decide who legally
employs a person.
Logging
console.log, console.error, console.warn and console.debug are banned in
src/ outside test files, and no-console is an ESLint error.
The
lib logger is client-safe (browser and SSR) and deliberately does not import the
App Insights sink; the sink dynamically imports the Node-only package and is
server-only.
The single sanctioned exception is the logger sink itself, with a file-level disable.
Do not add new eslint-disable no-console directives.
Every security fix ships with tests
A security fix without an attack-proof test is incomplete. Required:1
The attack
Send the exact payload from the ticket — an IDOR ID swap, an injected string, a path traversal,
a missing auth header — and assert the correct rejection status.
2
The auth boundary
Call the route with no session; assert
401.3
The permission boundary
Call it authenticated but under-privileged; assert
403.Authentication specifics
- Roles are verified against the database, never from a cached token.
- Session and CSRF cookies are
Secureby default; the opt-out exists only for plainhttp://localhost. - Magic links are signed and expiring — 60 minutes by default, seven days for the onboarding link — and compared in constant time.
- The dev-login route requires
ENABLE_DEV_LOGIN="true", a non-productionNODE_ENV, and a non-production host. All three. ALLOWED_EMAIL_DOMAINSshould be set in production; falling back to the Entra tenant boundary alone is legacy behaviour and is logged as a warning.