Threat model and limits
Who might attack what, what stands in the way, and which test fails if the defence is removed. Then the list of things this reference application does not do.
What it defends against
The assets are accounts, workspaces' data, and the audit trail. The attackers considered are a stranger on the internet, a signed-in person in another workspace, and an administrator of a workspace who controls an identity provider. The table names the defence and the file that holds the tests for it.
| Threat | What stands in the way | Pinned by |
|---|---|---|
| A forged or edited SAML response | Only a signature by the certificate stored on the connection is accepted, over the assertion at least. Unsigned messages, other keys and SHA-1 are refused, and so is any edit made after signing. Issuer, audience and recipient are each checked. | tests/saml.test.ts |
| Replay of a captured response | Gatehouse writes a row for every request it sends. A response is accepted only if it answers an unexpired, unused row, and using the row is one conditional update, so two copies delivered at once cannot both win. The assertion's own validity window is checked too. | tests/saml.test.ts |
| Login CSRF: an attacker signs the victim in as the attacker | The browser that starts a sign-in carries a signed flow cookie, and the response is accepted only from the browser that started it. A response planted in another browser is refused and the original request stays usable. | tests/saml.test.ts |
| A response nobody asked for (identity-provider-initiated) | Refused as unsolicited. Sign-in begins at Gatehouse only. | tests/saml.test.ts |
| XML tricks | A document that declares a DTD is refused before anything is parsed, which removes entity expansion and external entities in one rule. | tests/saml.test.ts |
| Taking over an account by provisioning or linking | An identity attaches to an existing account only inside the workspace; an address that belongs to an account outside is refused. The workspace comes from Gatehouse's own record of the request, never from the response. A SCIM token never attaches to an existing account, and the last active owner cannot be deactivated. | tests/saml.test.ts, tests/scim.test.ts |
| Access that outlives employment | A deactivated membership is refused at sign-in whatever the directory says, and deactivating by SCIM ends the person's sessions at once. Sessions otherwise last seven days (eight hours when started by SAML, two in the demo). | tests/scim.test.ts |
| Open redirect after sign-in | next and the SAML relay state are accepted only as a path on this site. Anything with a scheme or host, a protocol-relative or backslash start, a control character, or a path back into sign-in falls back to the app home. | src/lib/safe-redirect.test.ts |
| One workspace reading another's data | Application code cannot import the database at all (a lint rule); it reaches data through repository functions, and those that touch a workspace demand a TenantContext, a branded value only one function can produce after proving an active membership (ADR-0003). | tests/tenancy.test.ts, tests/db-guards.test.ts |
| Guessing passwords, codes and tokens | Argon2id for passwords. Sign-in allows 8 tries a minute, sign-up 5, a code or recovery code 8, a passkey 10. SCIM tokens are 256 random bits, rate-limited per token, and failures are limited per address. | tests/auth.test.ts, tests/scim.test.ts |
| Server-side request forgery through the report card | See the section below. The short version: deny by default, check what is dialled. | src/lib/report-card/*.test.ts, tests/report-card.test.ts |
| Editing the history | The audit table is append-only in the database itself: triggers refuse UPDATE, TRUNCATE and a direct DELETE, and only deleting the whole workspace removes rows. Addresses in it are keyed hashes. | tests/db-guards.test.ts |
| A script injected into a page | A content security policy limits where it could send anything, plus frame, MIME and referrer headers. It does not stop an inline script running (ADR-0004), which is why the report card grades this site a B and says why. | src/lib/security-headers.test.ts |
SAML in detail
The picture of one full sign-in, with the checks, is on How Gatehouse works. Three of the checks carry most of the weight and are easy to get subtly wrong, so they are worth a closer look.
Login CSRF
The attack: the attacker starts a sign-in as themselves, stops before the last step, and gets the victim's browser to deliver the attacker's response. The victim is now signed in to the attacker's account and may type secrets into it. The defence has a wrinkle: the identity provider's answer is a cross-site POST, which a normal strict cookie would not accompany. The flow cookie is therefore set SameSite=None; Secure (on HTTPS), it is signed with the application secret, and it only identifies a flow; it grants nothing by itself. The response must also answer a request recorded for that same flow.
Replay
A response is a bearer document for as long as it is valid. Gatehouse makes it single-use by consuming the request row it answers in one statement that only succeeds if the row is unexpired and unconsumed. The check and the consumption cannot be separated, which is the point: with two statements, two parallel deliveries both pass the first.
Provisioning rules
A just-in-time sign-in creates or links an account, which is where identity providers become dangerous. The rules are on How Gatehouse works: the workspace comes from the request record; linking happens only inside the workspace; an address owned elsewhere is refused; the address counts as verified only for the built-in directory; the role is read again on every sign-in.
The report card fetch
The report card makes a request to an address a stranger typed, which is the textbook way to reach a network the stranger should not see. The defence is to check what is dialled rather than what was typed, and to refuse when unsure. Each check is a separate test, and so is the combination that defeats a name that answers differently the second time.
- Only
httpandhttps, only ports 80 and 443, no credentials in the address. - Addresses are accepted from an allow list of what is globally routable (IPv6 only inside
2000::/3, minus Teredo, ORCHID and documentation ranges), not from a deny list. A mapped or 6to4 address is judged by the IPv4 address inside it; NAT64 is refused. - Every answer for the name must be public. One private answer refuses the name, because a mixed answer is what DNS rebinding looks like.
- The connection goes to the address that was checked, with the name kept for the Host header and TLS. A name is never resolved a second time between the check and the use.
- Redirects are followed by hand, at most 5, each one parsed, resolved and checked again. A broken certificate is a refusal, not a warning.
- Time and size are capped: 3 seconds a hop, 8 overall, 32 KB of headers, and the first megabyte of HTML at most (only to look for a meta policy). Cookie values never leave the parser. Checks are limited to 10 an hour per address.
What it does not do
Stated plainly, because a reference application that hides these is worse than none.
- No single logout. Signing out of Gatehouse does not sign out of the identity provider, and the provider cannot end a Gatehouse session.
- SAML is service-provider-initiated only, assertions are signed but not encrypted, and connections are added as database rows (see Connect Okta or Entra). It has not been tried against a live Okta or Entra tenant.
- The built-in identity provider shares a process with Gatehouse, so it shows the protocol, not two organisations on two domains (ADR-0001).
- SCIM covers Users only: no Groups, bulk, sorting, ETags or password changes. Directory groups reach Gatehouse as roles through the SAML assertion.
- Google and Microsoft sign-in are wired and untested against the real providers; they show as not configured here.
- Policy rules and members are read-only in the interface. The engine takes rules as data and the policy tester is live; the editor is not built.
- The content security policy allows inline scripts (ADR-0004), so it narrows what an injected script could do without preventing it from running.
- No mail: no address verification, password reset or invitations (ADR-0005).
- Passkey attestation is not verified. Any authenticator is accepted.
- Rate limits key on the first address in
x-forwarded-for, which Vercel's proxy sets. Behind a proxy that does not overwrite it, a client could pick its own bucket. - The three demo people are shared, so their security settings are off, and the sessions page lists only the visitor's own.
- Tested in Chromium only. Other browsers are not part of the end-to-end suite.