Securing the portal

On this page

What Office Sentry does by itself to protect the portal, what the person running it has to do, and how to put it behind a reverse proxy. The security model and how to report a vulnerability are in SECURITY.md; the full install guide is Running it on your own server.

Office Sentry holds the certificate that reads every client tenant, so the portal is the thing to protect. Treat its server like a domain controller: only the people who run it get a shell, and nothing else runs on it.

What it does by itself #

The defaults are the secure ones. Nothing below needs switching on.

What
Sign-in Passwords hashed with scrypt; 12 characters at least, and the 20,000 most common long passwords, the username and the product name are refused. Two-step codes from an authenticator app, required for everyone on a new install. Wrong passwords slow down the address they come from, never lock the account (Sign-in protection).
Sessions Kept on the server: the cookie holds a random token. Two hours idle and eight in total, ended on sign-out and on any change to the account. __Host- cookie, HttpOnly, SameSite, Secure over HTTPS.
Who sees what Every page and download checks the signed-in person's role and tenants on the server. Clients see their own tenants only. Certificates, private keys and the encryption key can't be downloaded by anyone.
Forms Every form and autosave carries an anti-forgery token. A missing or stale one is refused, explained, and counted in the activity log.
Browser headers A nonce-based Content-Security-Policy with no external scripts, styles or fonts, HSTS, X-Frame-Options: DENY, nosniff, Referrer-Policy: same-origin, a Permissions-Policy, cross-origin opener and resource policies of same-origin, and Cache-Control: no-store on every page.
Uploads Requests over 1 MB are refused, which covers the only uploads: a logo and a tenant list to import.
Activity log Every sign-in, refused page, change and download, with the address it came from. Admins read and export it under Settings → Activity log.
Secrets The session key and the encryption key for stored certificates, two-step secrets and mail credentials are generated into secrets.json (mode 0600) in a volume of its own, apart from the data and its backups.
Error pages The portal's own pages, with no stack traces, versions or server names.
Containers The portal and worker run read-only, as a non-root user, with every Linux capability dropped, no-new-privileges and a process limit. The compose file binds the portal to 127.0.0.1 only.
Logs The access log records the path, status and timing of each request, never the query string or the referer.
PDFs Rendered on the server from the app's own templates, with a fetcher that loads only the app's static files: no other files, no network.
Microsoft 365 Read-only Graph permissions and Get- Exchange cmdlets only; the certificate's private key never leaves the server. What Office Sentry reads lists every permission.

What you have to do #

  • HTTPS only. Run it behind a TLS-terminating reverse proxy (the next section). Cookies are marked Secure, so signing in over plain HTTP doesn't work, by design. OFFICESENTRY_INSECURE_COOKIES=1 is for development on a laptop and nothing else.
  • Tell it how many proxies are in front with OFFICESENTRY_TRUSTED_PROXIES (1 for the bundled Caddy or one proxy of your own). With it at 0, every visitor appears to come from the proxy and shares its sign-in limits. Never set it higher than the number of proxies you really have, or visitors could pick their own address.
  • Keep the firewall to 80 and 443 (and SSH from your own addresses). The portal's own port 8000 is bound to the loopback interface, so a proxy on the same host reaches it and nothing else does. Don't change the bind address to put a proxy on another host without a private network between them.
  • Back up, and keep the keys apart. Nightly backups go in the data volume; copy them off the server encrypted (DEPLOY.md, "Off-site backups") and run the restore drill. Keep a copy of secrets.json somewhere safe that isn't with the backups: without the encryption key the stored certificate can't be read and must be replaced.
  • Update. Each release's notes say what changed; a line under Security in the changelog means update soon. The image is rebuilt with current base packages on every release.
  • Watch the activity log now and then, or send its sign-in failures to a webhook (Hear about problems without signing in). Refused pages (someone opening a tenant they haven't been granted) and stale form tokens are counted per person and hour.
  • Give out the least. Analysts see every tenant; clients see theirs. Make a tenant group when a client has several tenants rather than giving the analyst role. Remove accounts the day someone leaves: disabling one ends their sessions at once.

Behind a reverse proxy #

The portal expects a proxy in front of it that ends TLS and passes two headers: X-Forwarded-For with the visitor's address and X-Forwarded-Proto with https. With OFFICESENTRY_TRUSTED_PROXIES=1 the portal trusts the last hop of those headers and ignores anything the visitor put there themselves.

Caddy (the bundled Caddyfile, started with --profile https) does all of this by itself, including the certificate. Nothing to configure beyond OFFICESENTRY_DOMAIN in .env.

nginx:

server {
    listen 443 ssl;
    http2 on;
    server_name sentry.yourmsp.com;
    ssl_certificate     /etc/letsencrypt/live/sentry.yourmsp.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/sentry.yourmsp.com/privkey.pem;
    server_tokens off;
    client_max_body_size 2m;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-For   $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 120s;   # a long PDF can take a minute
    }
}
server {
    listen 80;
    server_name sentry.yourmsp.com;
    return 301 https://$host$request_uri;
}

Use $remote_addr, not $proxy_add_x_forwarded_for: the portal only wants the address the proxy itself saw, and appending to a header the visitor sent lets them choose what's logged.

Traefik passes both headers by default. Set forwardedHeaders.insecure off (the default) and trustedIPs only to proxies you run, and give the router a websecure entry point with a certificate resolver. No middleware is needed for the headers.

Cloudflare or another CDN in front of your proxy counts as a second hop: set OFFICESENTRY_TRUSTED_PROXIES=2 and make your proxy accept connections from the CDN's addresses only, otherwise anyone who finds the origin can bypass the CDN and name their own address. The portal sets its own security headers and Cache-Control: no-store, so leave the CDN's caching and header rewriting off for this host.

Checking it worked. Sign in, then look at Settings → Activity log: the sign-in's address should be yours, not 127.0.0.1 or the proxy's. The portal also logs a warning the first time a forwarded header arrives while OFFICESENTRY_TRUSTED_PROXIES is 0. In the browser's developer tools, the response should carry Strict-Transport-Security; if it doesn't, the proxy isn't passing X-Forwarded-Proto: https and the portal thinks it's on plain HTTP.

Without Docker #

Running on Windows without Docker covers the service. The same rules apply: a reverse proxy with TLS in front, OFFICESENTRY_TRUSTED_PROXIES set, the data folder and secrets.json readable by the service account only, and the port the portal listens on reachable from the proxy alone.