Skip to content

Security checklist

What to check before a Kosmos server holds real client data, and what the code does and does not do for you. Each item says where the behaviour comes from, so you can confirm it against the version you run.

The short list

  1. DEBUG=False, a private SECRET_KEY, ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS set to your host, EMAIL_BACKEND=smtp. See Core settings.
  2. TLS issued with certbot before anyone signs in, and the port 80 redirect in place. See TLS and nginx.
  3. KOSMOS_SEAM_KEY and MAILGUN_WEBHOOK_SIGNING_KEY set only for the features you use. While blank, their endpoints refuse every request. See Endpoints that stay closed until you set a secret.
  4. PAYMENT_PROCESSOR set to none, or to a real processor with its webhook secret. Never fake on a server that emails invoices. See Payment webhooks.
  5. Superuser accounts kept few. See Sign-in.
  6. config/.env and the Google token directory readable only by the service account. See File permissions.
  7. media/ never served by the web server. See Uploaded files.
  8. Every new user's permissions reviewed, with the limits of those permissions understood. See Access inside the firm.

Core settings

All of these live in config/.env. Their defaults are in the environment reference.

Setting What to set Why
DEBUG False With it on, error pages show source code and settings, Django serves media/ to anyone when storage is local, OAuth is allowed over plain HTTP, email defaults to the console, and the cookie and proxy settings under Django security settings are not applied.
SECRET_KEY A long random value used nowhere else It signs sessions, password-reset links, payment links and intake-form links, and encrypts AI keys entered under Settings → Integrations. Anyone who has it can forge them. The value in config/.env.dev is public.
ALLOWED_HOSTS Your host name only Requests for any other host are refused.
CSRF_TRUSTED_ORIGINS https://kosmos.example.com The origin Django accepts form posts from, in addition to the host the request itself names.
ENV prod
EMAIL_BACKEND smtp While it is console, sign-in codes and password-reset links are written to the log instead of being sent.

When scripts/install.sh --prod generates config/.env, it writes the first five, generates the key and sets PAYMENT_PROCESSOR=none. It leaves EMAIL_BACKEND=console and every integration secret blank. It never edits a config/.env that already exists, so on a server that began as a development install, check each of these by hand.

Changing SECRET_KEY later signs every user out and invalidates every payment link, intake-form link and password-reset link already sent. AI keys entered under Settings → Integrations can no longer be read either, and must be entered again (see AI providers and research).

TLS and nginx

The site file the installer renders, deploy/nginx/kosmos.conf, listens on port 80 only. Until you run certbot, nobody can sign in: a production instance marks its cookies Secure, and a browser does not keep or return such cookies over plain HTTP (see Django security settings). Anything typed into the sign-in form in the meantime still crosses the network unencrypted. Run certbot before you hand out an account.

sudo certbot --nginx -d kosmos.example.com

Certbot rewrites the site file with the TLS listeners and the redirect from port 80. The installer does not overwrite a certbot-managed file unless you pass --force.

What the templates add:

File What it does
kosmos-security.conf Sends X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff and Referrer-Policy: strict-origin-when-cross-origin on responses, including error responses. Refuses any path with a component that starts with a dot (.env, .git).
kosmos-ratelimit.conf Defines two request-rate zones keyed on the client address: general at 10 requests a second and login at 5 requests a minute. Installed only when no other nginx file already defines a login zone.
limit-login.conf Applies the login zone with a burst of 5 to paths that begin /accounts/login. That covers the password step and both code steps.
kosmos.conf Applies the general zone with a burst of 20 to everything else that reaches the application. Limits request bodies to 100 MB. Serves /static/ straight from the checkout. Has no location for media/. Passes the request on with the distribution's proxy_params, which set Host, X-Forwarded-For and X-Forwarded-Proto.

What they do not add:

  • No Strict-Transport-Security header and no Content-Security-Policy.
  • The three security headers are not sent on /static/ responses. Those two locations set their own Cache-Control header, and nginx does not inherit server-level add_header lines into a location that has its own.
  • A request over the limit is answered with nginx's default status for limit_req, which is 503.
  • /accounts/password_reset/ is under the general zone only.
  • /static/ and /favicon.ico are not rate limited.

To add HSTS, put this in the TLS server block of the certbot-managed site file once you are sure the site will stay on HTTPS:

add_header Strict-Transport-Security "max-age=31536000" always;

Django security settings

config/settings.py reads only the variables in the environment reference. None of the settings below can be changed from config/.env, except that three of them follow DEBUG.

Setting In effect Consequence
SECURE_PROXY_SSL_HEADER Set when DEBUG is off: ("HTTP_X_FORWARDED_PROTO", "https") Django takes the scheme of a request from the X-Forwarded-Proto header that nginx sets. Addresses it builds from a request, such as the configuration block on Settings → Claude Desktop, begin with https://.
SESSION_COOKIE_SECURE Set when DEBUG is off: on The session cookie is marked Secure. A browser sends it over HTTPS only.
CSRF_COOKIE_SECURE Set when DEBUG is off: on Same for the CSRF cookie.
SECURE_SSL_REDIRECT Not set (off) Django never redirects to HTTPS. Certbot's nginx redirect is the only one.
SECURE_HSTS_SECONDS Not set (0) No HSTS header from Django.
SESSION_COOKIE_HTTPONLY Django default (on) Scripts cannot read the session cookie.
SESSION_COOKIE_SAMESITE, CSRF_COOKIE_SAMESITE Django default (Lax)
SESSION_COOKIE_AGE Set: 56 days See session length.
SESSION_SAVE_EVERY_REQUEST Set: on Each request renews the 56 days.
X_FRAME_OPTIONS Set: SAMEORIGIN
SECURE_CONTENT_TYPE_NOSNIFF Django default (on)
SECURE_REFERRER_POLICY Django default (same-origin) Application responses carry this value and the one nginx adds.
AUTH_PASSWORD_VALIDATORS Set: Django's four standard checks Applied by password reset and createsuperuser only. See Add a user.
Content Security Policy None

What this leaves to you:

  • TLS has to be in place before the first sign-in. With DEBUG off, both cookies are Secure, so a production instance cannot be signed into over plain HTTP.
  • Django believes the X-Forwarded-Proto header. The proxy_params file that the nginx template includes sets it from the real scheme of each request, replacing anything the client sent. If you write your own proxy configuration, set the header the same way.
  • Nothing sends an HSTS header, and only nginx redirects port 80. Keep that redirect, and add the HSTS header above.

Sign-in

How sign-in works is described in Users and permissions. The points that matter for hardening (apps/accounts/views.py):

  • Sign-in is by email address. The match is case-blind, and a database constraint keeps each non-blank address to one user. A wrong address and a wrong password get the same reply, and a hash is computed either way, so neither the message nor the response time says which addresses exist.
  • Failed sign-ins are counted per email address as typed, in the database (SignInThrottle), so the count is shared by every web worker. After five failures (FREE_FAILURES) a cooldown starts at 30 seconds and doubles with each further failure, up to 15 minutes. During it the password is not checked at all. Wrong authenticator codes count toward the same cooldown; wrong emailed codes have their own limit below. A completed sign-in clears the count; an hour without a failure resets it; rows nobody has tried in a day are dropped. Since the key is the address and not the client, someone who knows a user's address can keep that user waiting up to 15 minutes at a time. The nginx login zone is the brake on that and on guessing across many addresses. Do not remove it, and do not put the application behind a proxy that hides client addresses from nginx.
  • The emailed code is six digits, generated with Python's secrets module, and stored unhashed in the database until it is used, replaced, found expired or discarded.
  • The emailed code expires five minutes after it was created.
  • After five wrong emailed codes (MAX_CODE_ATTEMPTS) the code is deleted and the user has to pass the password step again, which issues a new code. The count is kept on the code itself, so it is the same five guesses however many browser sessions try it.
  • An authenticator app, once set up, replaces the emailed code for that user; there is no way back to the emailed code at sign-in. Codes are TOTP (RFC 6238, 30-second steps, one step of drift allowed), compared in constant time, and each accepted step is recorded, in one guarded update, so a code is good once. The secret is stored in clear on its own row (Authenticator): encrypting it with a key kept on the same server would protect only a database copy taken without config/.env, and would sign the whole firm out of their apps whenever SECRET_KEY changed. A database backup therefore carries the seeds, as it carries the password hashes. An administrator can require the app for every user under Settings → Security; see The authenticator app.
  • The address a sign-in link asks to return to (next) is followed only when it stays on the same host. Any other value is dropped and the user lands on the task list.
  • There is no Django admin site, so there is no password-only form and no screen that edits records outside the application's rules.

Uploaded files

Documents, invoice PDFs and email attachments are confidential and are never given a public address.

  • With STORAGE_BACKEND=local, files are kept in media/. Django routes that directory only when DEBUG is on (config/urls.py), and the nginx template has no location for it. Files reach a browser only through views that require a signed-in session or, for an invoice PDF, the signed link emailed to the client. config/tests/test_media_security.py holds this in place.
  • The one exception is media/company/, which holds the firm logos. It is served to anyone at /media/company/… because the public payment and intake pages show it. Upload nothing else there.
  • With STORAGE_BACKEND=s3, nothing under media/ is routed. Files are read through the storage API, and links to them are signed and expire. Kosmos sets no access rule on uploads, so keep the bucket private.

Never add an nginx location or alias for media/.

Document downloads and the document viewer are under /case/, so they check matter membership as well as the session. See Access inside the firm.

Endpoints that stay closed until you set a secret

Two shared secrets are blank after a fresh install. The endpoints they guard are routed whether or not you use the feature, but while a secret is blank its endpoints refuse every request with HTTP 403. If you do not use a feature, leave its secret blank.

KOSMOS_SEAM_KEY

Guards three endpoints meant for a firm's own website or intake application (apps/intakes/api_views.py). A caller must send the key in an X-Seam-Key header, which is compared with the setting in constant time. A caller that holds the key can:

Endpoint What a caller with the key can do
GET /api/intakes/search/ Read prospective clients' names, phone numbers, email addresses, status and practice area. A blank query returns the 20 most recent open intakes.
POST /api/receive-inquiry/ Create an intake, or add a note to an existing intake that matches an email address or phone number.
POST /api/receive-intake/ Create an intake, add a note to any intake by its number, or replace the text of an existing note on that intake.

These endpoints have no application rate limit, and the key is the only thing that protects them. Generate a long random one:

python3 -c 'import secrets; print(secrets.token_urlsafe(32))'

Put the output in config/.env as KOSMOS_SEAM_KEY=…, restart the web service, and give the calling application the same value. Every request without that exact header is refused with HTTP 403.

MAILGUN_WEBHOOK_SIGNING_KEY

Guards POST /api/inbound-email/ (apps/intakes/inbound.py). A post must carry Mailgun's HMAC-SHA256 signature made with this key and a timestamp no more than five minutes old. A post that passes is then accepted only when the recipient matches INTAKE_INBOUND_RECIPIENT and the From address belongs to an active user. While the variable is blank no signature can be verified, and every post is refused with HTTP 403.

To use the feature, set the key as described in Intakes from forwarded email.

Payment webhooks

POST /webhooks/<processor>/ needs no sign-in. The processor is chosen by the name in the path, not by PAYMENT_PROCESSOR. The view always answers 200 and hands the body to the background worker, which verifies it (apps/invoicing/pay/reconcile.py). An event that fails verification is logged and ignored. A verified event can only change a payment or trust deposit that Kosmos itself recorded for that processor with that transaction id.

Path How a delivery is verified If the secret is blank
/webhooks/lawpay/ LawPay does not sign. The body is used only to learn a transaction id; Kosmos then fetches that transaction from the LawPay API with LAWPAY_SECRET_KEY and acts on the fetched status. The processor does not load without LAWPAY_SECRET_KEY, and the event is ignored.
/webhooks/stripe/ The Stripe-Signature header is checked against STRIPE_WEBHOOK_SECRET. A verified payload is trusted as sent. Every delivery is rejected. The processor loads with STRIPE_SECRET_KEY alone, so check that the webhook secret is set too.
/webhooks/confido/ The X-Signature header must be the HMAC-SHA512 of the raw body under CONFIDO_WEBHOOK_SECRET. Kosmos then fetches the transaction from Confido and acts on the fetched status. Every delivery is rejected.
/webhooks/fake/ Not verified. The simulated processor trusts the body, but acts only on simulated transactions held in the memory of the process that handles it. Not applicable.
/webhooks/none/ Every delivery is rejected. Not applicable.

When deliveries are rejected or never arrive, payments stay as they were recorded until something asks the processor. The worker's hourly payments-reconcile job does that for every payment and trust deposit still in flight, and manage.py reconcile_pending does the same on demand. It is listed in the command reference.

PAYMENT_PROCESSOR decides what the public payment page does. Invoice emails carry a link to that page.

  • none switches online payment off. The page shows the amount due and the invoice or statement to download, and asks the client to contact the firm. Nothing can be charged or recorded. Invoice emails link to the page as "View invoice" instead of "Pay now", and payment and trust deposit requests cannot be sent. The installer writes this value for a production install.
  • fake is for development. The page shows a simulated form, and submitting it records the invoice as paid although no money moves. It is the built-in default when the variable is missing, and the value in config/.env.dev. On a server that emails invoices, the recipient could mark an invoice paid.
  • lawpay, stripe and confido are the real processors. Setting them up is in Online payments.

A trust deposit is refused, not sent to the wrong account, when the processor cannot guarantee where it lands: always under Stripe, which pays into a single account, and under LawPay or Confido while the trust account id is blank. When a processor cannot serve a payment page as configured (a missing key or account id, an unknown processor name), the client sees "Online payment is not available right now. Please contact us." with HTTP 503, and the reason is written to the log.

Application rate limiting

The public payment, intake-form, inbound-email and webhook routes have their own limits in addition to nginx (utils/ratelimit.py). The figures are in the table of routes that need no sign-in. Know their limits:

  • The counters live in Django's default cache, which config/settings.py sets to in-process memory. Each gunicorn worker counts separately, so with the template's three workers a client gets up to three times the stated figure. The counters are lost when the service restarts.
  • The client address is the last entry of the X-Forwarded-For header, which is the one nginx appends, so a client cannot choose it. If another proxy sits in front of nginx, that entry is the proxy's address and every client behind it shares one counter.
  • Sign-in, password reset, the three KOSMOS_SEAM_KEY endpoints, the health checks and the token-authenticated APIs have no application limit at all.

Treat these limits as a brake on scripted abuse, not as access control. The signed token in each public link is the access control.

Access inside the firm

The role and permission switches are described in Users and permissions, and every check is listed in the permissions matrix. Before you rely on them:

  • A new user has every permission except Reports until you switch some off. Reports starts off, because it shows the whole firm's figures.
  • Restricting a user to assigned matters closes those matters' pages, case workspace and document downloads to everyone else, and leaves them out of search. It does not filter the task list, the calendar or contacts, so it is not an ethical screen.
  • The permission switches are enforced by address, with one leak that the matrix lists: rates, fees and amounts in the time, expense and flat-fee lists.
  • Nothing stops an admin from demoting or deactivating the last admin. Recovery is createsuperuser from the shell.
  • A user's API token (Claude Desktop, LibreOffice companion) never expires and is stored unhashed. It stops working when the user is deactivated and works again if they are reactivated. Only the user can rotate or revoke it.
  • Deactivating a user does not disconnect a Gmail mailbox they connected. It keeps synchronizing until the user disconnects it.

File permissions

Path Holds What the installer does What to do
config/.env SECRET_KEY, the database password, every API key Writes it with mode 600 If you created it by hand: chmod 600 config/.env
google/ (or GOOGLE_DATA_DIR) The Google OAuth client secret and the Calendar, Contacts and Drive tokens Creates the directory with default permissions. The application writes the token files with default permissions too chmod 700 google and chmod 600 google/*.json
media/ Client documents, when storage is local Creates the directory with default permissions chmod 700 media
logs/ Application, gunicorn access and error logs. Access logs record full request paths, which include payment-link and form-link tokens Creates the directory with default permissions. Installs /etc/logrotate.d/kosmos, which rotates the files weekly and keeps twelve copies (see Log rotation) chmod 700 logs
The database Everything else, including each user's Gmail token, each user's API token and unexpired sign-in codes, all stored unencrypted Creates a role with a random password in production Restrict who can read the database and its backups

The web service and the worker both run as the account that owns the checkout, so owner-only permissions are enough for these four paths. nginx runs as www-data and needs only static/ and the directories above it.

Change history

Kosmos records changes with django-simple-history. Each time a tracked record is created, changed or deleted, a full copy of the row is stored with the time, the kind of change and, for a change made in the browser, the signed-in user. Changes made by the background worker or a management command carry no user.

Tracked: users, contacts, matters and their proceedings, rates and settlements, tasks, calendar events, checklists, time, expense and flat-fee entries, invoices, payments, credits and their applications, payment requests, trust transactions, intakes and intake forms, documents and the other case records, notes, and AI conversations. Firm settings and synced email are not tracked.

Limits:

  • It records writes only. Nothing records who viewed or downloaded a record. Sign-ins are not logged beyond the "last login" time on the user.
  • History is shown in the Django admin for the models registered there. There is no history screen in the application itself.
  • History rows for users include the password hash as it was at the time. Protect database backups accordingly.

History grows without limit until you prune it. clean_history deletes history rows older than a number of days (90 unless you pass --days) from every history table, and the worker's failed task records from before the same cutoff. It is not scheduled. Decide how long your firm must keep an audit trail, then run it by hand or from cron:

.venv/bin/python manage.py clean_history --days 365 --dry-run
.venv/bin/python manage.py clean_history --days 365

The first form reports what would be deleted and changes nothing.

What leaves the server

Each of these is off until its keys are set.

  • AI providers (Anthropic, Google Gemini): the matter material a chat or summary draws on (document text, notes, emails, timeline), text sent for semantic indexing, forwarded intake email, the firm's user names and titles, and the name and email address of the user who asked. Keys are in the environment reference or, encrypted in the database, under Settings → Integrations. With no AI key, nothing is sent and every AI feature is hidden.
  • Google Workspace: calendar events and contacts are synchronized in both directions. Drive files and Gmail messages are read into Kosmos. See Google Workspace.
  • Payment processor (LawPay, Stripe or Confido): the amount, a reference such as the invoice number, and, depending on the processor, the client's name, email address and phone number and the matter name. Card and bank details go from the client's browser to the processor and never reach Kosmos. Keys are in the environment reference.
  • CourtListener: research queries, the citations being checked, and the text of each AI chat reply, which is posted to its citation lookup. See AI providers and research.
  • Mailgun: receives mail forwarded to the intake address before Kosmos does. See Intakes from forwarded email.
  • Your SMTP provider: sign-in codes, password resets, invoices and payment requests, daily digests, and error reports to ADMINS, which include details of the failed request.
  • Object storage, when STORAGE_BACKEND=s3: every uploaded file.
  • Claude Desktop, for users who set it up: whatever that user's Claude reads from Kosmos goes to their machine and to Anthropic under their own account. See Claude Desktop.
  • Users' browsers load scripts and styles from unpkg.com, cdn.jsdelivr.net and Google Fonts on every page, and the processor's script on the payment page. The pages do not pin these files with integrity hashes.

Reporting a vulnerability

Follow SECURITY.md: report it privately by email to info@kosmos.law, and do not publish details until the fix is released.