Skip to main content
These are hard rules. Violating any of them is an automatic blocking review finding.

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:
A 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 a Location: 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 in src/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

Never === 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 Secure by default; the opt-out exists only for plain http://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-production NODE_ENV, and a non-production host. All three.
  • ALLOWED_EMAIL_DOMAINS should be set in production; falling back to the Entra tenant boundary alone is legacy behaviour and is logged as a warning.