Security review (release 1.0)
This page is the review record as it stands in docs/security-review.md.
Re-run the review for every minor release, and whenever authentication,
the agent executor or the deployment changes.
The Phase 6 security review of the whole system: the backend, portal, agent, connector and production deployment. Each item was checked against the code, not the design documents. The evidence gives file references and the tests that keep each property true. Fixes made during the review are marked fixed in 1.0.
Reviewed 2026-09-24 against main (Phase 6). Automated checks are listed
at the end. Re-run this review for every minor release, and whenever
authentication, the agent executor or the deployment changes.
Threat model in one paragraph
A single installation serves many tenants (MSP customers). The attackers we consider:
- a user of one tenant who wants data or control in another tenant;
- a lower role (viewer or technician) who wants more than their role grants;
- anyone on the Internet who can reach the portal, the agent and connector endpoints, and the files host;
- a standard (non-admin) Windows user on a managed PC who wants SYSTEM through the agent;
- someone who obtains a copy of the database, a backup, a log file or a URL.
These are trusted by design:
- the agent runs as SYSTEM and executes what the server sends;
- a tenant's package managers and script authors can run any code on that tenant's devices;
- a global admin controls everything.
Checklist
| # | Item | Status |
|---|---|---|
| 1 | Session cookie flags | OK (on the Hub since phase 7) |
| 2 | CSP and security headers | OK, fixed in 1.0 (files host) |
| 3 | CORS origins from config | OK, fixed in 1.0 (validation) |
| 4 | Upload type/size allowlist | OK, fixed in 1.0 |
| 5 | Path traversal on object keys | OK |
| 6 | SSRF | OK |
| 7 | LDAP injection | OK |
| 8 | Command injection (agent/connector) | OK, fixed in 1.0 (3 findings) |
| 9 | Archive/MSI bombs, size limits | OK, fixed in 1.0 (push MSI hash) |
| 10 | Secrets in logs | OK, fixed in 1.0 |
| 11 | Agent key revocation on decommission | OK, fixed in 1.0 (other replicas, suspension) |
| 12 | Enrollment token brute force | OK |
| 13 | WebSocket origin checks | OK |
| 14 | SSE token leakage | OK, fixed in 1.0 (POST /auth/sse-token) |
| 15 | Tenant isolation (authz + RLS) | OK |
| 16 | Credentials, tokens, key rotation | OK |
| 17 | Supply chain (scanners, pinning, SBOM) | OK |
1. Session cookie
- Since phase 7 the only session cookie is the Hub's
platform_rt:HttpOnly,SameSite=Strict,Securein production, withPath=/api/platform/v1/auth(Hub API). Axis sets no cookies. - Product tokens (5 min) live in memory in Axis's SPA, never in storage.
2. CSP and headers
The portal (deploy/Caddyfile) sends:
default-src 'self',script-src 'self'(the SPA has no inline script),style-src 'self' 'unsafe-inline'(component inline styles),img-src 'self' data:, andconnect-src 'self' https://<files host>(browser package uploads go to presigned URLs);frame-ancestors 'none',object-src 'none'andbase-uri 'self';- HSTS, nosniff,
X-Frame-Options: DENY, Referrer-Policy and Permissions-Policy.
Fixed in 1.0: the files host (MinIO behind Caddy) now sends
default-src 'none'; sandbox, and objects are always stored as
application/octet-stream (see 4). An uploaded file can never become a
page that runs script.
If S3 is not served on RMM_FILES_DOMAIN (external S3), add that endpoint
to connect-src.
3. CORS
- Allowed origins come only from
RMM_CORS_ORIGINS, and credentials are allowed only for them (middleware/cors.go). - The production portal is same-origin and needs no CORS.
- Fixed in 1.0: configuration is validated at startup. Wildcards,
paths and non-http(s) values are refused (
config.validOrigin,TestLoadFromMap_Validation).
4. Uploads
Package files:
- Extensions are allowed per kind:
.msi,.exe,.ps1. - Size is limited to 1 B–4 GiB.
- Finalize checks the stored size against the declared size, then hashes
the object on the server and compares it with the client's SHA-256
(
deploy/packages.go).
Releases: ≤ 500 MiB, with size, SHA-256 and a signature on publish. Script output uploads: capped at 10 MiB.
Fixed in 1.0:
- Presigned uploads always sign
Content-Type: application/octet-stream, whatever the client declares (storage/presign.go,TestPresignedPutRoundTrip). - An upload that fails the size or hash check is deleted at once
(
discardUpload,TestPackageCRUDScopesAndValidation).
Known limit:
- The presigned PUT does not bind the length. An oversized upload is rejected and deleted at finalize, but it is stored first.
- Drafts that are never finalized keep their object until the package is deleted.
5. Object keys
Keys are generated by the server:
packages/<tenant|global>/<uuid>/<sanitized name>.SanitizeFileNametakes the base name after mapping\to/, allows only[A-Za-z0-9._-]and strips leading dots.- Release keys come from a validated component and semver.
- Script output keys are
OutputKey(tenant, run).
Finalize requires the client's key to equal the stored key. Downloads always presign the stored key. The agent cache rejects bad names and non-hex hashes.
6. SSRF
The server fetches only:
- the winget index from
RMM_WINGET_SOURCE_URL(operator config, size capped); - SMTP and S3 at configured endpoints;
- its own
/readyz(healthcheck).
Alert notifications are e-mail only; there are no webhooks. The LDAP host of a sync configuration is contacted by the on-premises connector, not by the server.
7. LDAP injection
- No user value is placed in an LDAP filter. The computer filter is
(&(objectCategory=computer)<computer_filter>). computer_filteris written by the tenant admin by design. It is checked for balanced parentheses and length, so it stays inside the outer&(balancedFilterinentrosity-shared-go/proto/connector.go,entrosity-axis-connector/internal/ldap).- OU scoping is applied on the client side.
8. Command injection
In place:
- Processes are started with argument arrays: winget, shutdown, the PowerShell script wrapper (paths quoted, parameters passed as JSON and splatted) and the connector's msiexec properties (metacharacters refused; WinRM commands are constant).
- Package install and uninstall arguments are raw command lines by design. They are written by package managers, who can upload any executable anyway.
Fixed in 1.0:
- Uninstall from inventory (technician):
silent_argswas appended to the uninstall string and run throughcmd.exe /cas SYSTEM, so/S & …ran any command. Arguments with& | < > ^ % ! "or line breaks are now refused (proto.CmdSafe,device.UninstallFromInventory,TestDeviceActions). - Package uninstall by inventory name (deployment): the lookup matched
per-user (HKCU) uninstall entries. A standard user could plant one whose
name imitates the package and whose uninstall string runs their program
as SYSTEM. Only machine-wide entries are used now (
FindUninstallEntry,TestFindUninstallEntryIgnoresPerUserEntries). - cmd scripts: string parameters become
%RMM_PARAM_X%, which cmd expands before parsing the line. Values with cmd metacharacters are now refused forcmdscripts (scripts.cmdSafeParams,TestCmdScriptParamsRejectMetacharacters). PowerShell parameters are data and are not restricted.
Remaining low risk: AddProvisionedAppx passes file names from
server-generated object keys inside a -Command string.
9. Size limits and integrity
Size limits:
- Backend JSON bodies: 1 MiB.
- Agent inventory: 32 MiB compressed, 64 MiB decompressed.
- Job output: capped.
- Connector AD chunks: 16/32 MiB.
- WebSocket read limit: 1 MiB on both ends.
- Winget index: download and zip-entry limits.
Integrity:
- Agent downloads are size-bounded and hashed before they are renamed or executed. The cache is served only after a verified marker.
- Self-updates: signed manifest (Ed25519), size and SHA-256.
Fixed in 1.0: the agent MSI that the connector pushes to domain
computers (with domain credentials) now carries its SHA-256, which the
connector verifies. The server computes it once per object version
(adsync.msiHash, TestPushMSIHash). With a static RMM_AGENT_MSI_URL
there is no hash; prefer object storage.
Remaining low risk: the winget bootstrap files (App Installer bundles) are downloaded without a hash. Windows verifies their package signature.
10. Secrets in logs
- The request log records the chi route pattern, never the raw path or
query string. This keeps tokens in paths and
?sse_token=out of logs (TestLogger_NoSecretsFromURLs). - Fixed in 1.0: the process logger redacts attributes whose key names
a secret (password, secret, token, authorization, cookie, credential,
keys). IDs and times keep their values.
Bearer/Basicvalues in free-text errors are also redacted (logging.redactAttr,TestSecretsAreRedacted). - Fixed in 1.0: agent and connector download errors no longer contain
the presigned URL's signature (
transport.RedactURL). An undeliverable job message is logged by type and size, not its content. - Audit snapshots redact secret keys (
audit.Redact).
11. Revocation
- Decommissioning a device revokes the agent key, cancels its jobs and closes its connection on whichever replica holds it.
- The agent and connector authenticators refuse revoked keys, decommissioned devices and inactive tenants on WebSocket and HTTP polling alike.
Fixed in 1.0:
- Every replica drops its cached key on revocation. Before, other replicas trusted a cached key for up to 60 s.
- Suspending a tenant closes all its agent and connector connections on
all replicas and drops their cached keys (
Dispatcher.RevokeTenant,TestRevocationAcrossReplicas).
12. Enrollment tokens
- 32 random bytes (base64url). Only the SHA-256 is stored, and lookup is by hash.
- Rate limit per IP (
RMM_ENROLL_RATE_PER_MINUTE). - Optional expiry, use limit and revocation.
- Brute force is infeasible given the entropy.
13. WebSocket origin
- Agent and connector WebSockets use the library's origin check. A
browser
Originfrom another site is refused with 403; agents send no Origin (TestAgentWebSocketRejectsBrowserOrigins). - The portal has no WebSocket; it uses SSE.
14. Event stream tokens
Fixed in 1.0: EventSource cannot send headers, and the portal used to
put the 15-minute access token in ?access_token=. It now calls
POST /auth/sse-token, which returns a JWT with:
- its own audience (
rmm-events); - a 60-second lifetime;
- binding to one tenant and to the session.
The token opens only GET /tenants/{id}/events?sse_token=, and only for
that tenant. Tenant access is checked again when the stream opens.
?access_token= is no longer accepted, and a stream token is not an
access token (TestStreamTokens, events.stream.test.tsx).
15. Tenant isolation
First layer:
- Every portal route has an access rule (
portal/access.go). A route without one is refused. - Tenant routes resolve
{tenantID}against the principal. - 82 cross-tenant cases pass B's IDs to A's endpoints in paths, bodies and
filters. They expect 404/422 and no change to B
(
TestAuthzFuzzCrossTenantIDs).
Second layer: PostgreSQL row-level security (migration 0007).
- The server connects as
rmm_app(NOBYPASSRLS). Every request runs with the tenant inapp.tenant_id. - Composite foreign keys keep references inside a tenant.
- The audit log is append-only.
Design note: code paths without a tenant scope (workers, global admin routes, agent protocol before authentication) run with RLS bypass. The first layer and the fuzz test cover them. Making "no scope" deny by default is a candidate for 1.x.
16. Credentials and keys
- Passwords, TOTP secrets, sign-in limits and sessions live only on
Entrosity Hub since phase 7 (migration 0013 dropped them from Axis): the
Hub hashes passwords with argon2id (m=64 MiB, t=3, p=2), keeps sign-in
limits in PostgreSQL across replicas, encrypts TOTP secrets with
PLATFORM_MASTER_KEYand hashes recovery codes and session cookies. - Axis's own JWTs are only the event stream tokens: HS256, one minute, with issuer, audience, expiry and iat checked.
- Rotation (new in 1.0):
RMM_JWT_SECRET_OLDverifies old stream tokens during a JWT secret rotation.rmm-server rotate-master-keyre-seals all secrets under the new master key (TestRotateMasterKey).
- The server refuses to start in production with the development example secrets.
17. Supply chain
security.ymlruns on every push and weekly:- govulncheck (Linux and Windows builds);
- gosec (exclusions reviewed below);
pnpm audit;- gitleaks (full history);
- Trivy on the images (backend, web, Entrosity Hub) and on
deploy/; - SPDX SBOMs.
- Releases scan the images before pushing and attach SBOMs.
- Pinning:
go.sum,pnpm-lock.yamland image tags. Caddy is rebuilt fromdeploy/caddywith current dependencies, because upstream images lagged on Go and gRPC fixes. Go uses the patchedtoolchain go1.26.8. - Dependabot covers Go, npm, actions and Docker.
- The images run as non-root: distroless
nonrootfor the backend; uid 10001 with onlyCAP_NET_BIND_SERVICEfor Caddy.
18. Sign-in on Entrosity Hub (phase 7)
- Product tokens: EdDSA only (HS256 and
alg: nonerefused), knownkid, issuerRMM_PLATFORM_URL, audiencermm, nopurpose, expiry with 30 s leeway (entrosity-axis.backend/internal/platformauth,TestParseAccess). - Roles never come from the token; the RBAC matrix and the cross-tenant
fuzz test run with Hub product tokens (
TestRBACMatrix,TestAuthzFuzzCrossTenantIDs). Axis has no sign-in routes left (migration 0013). - Step-up tokens: bound to user and session,
jtirecorded inplatform_step_ups_usedso each works once on any replica (TestHubStepUp,TestVerifyStepUp). - The Hub's cookie routes refuse cross-site requests
(
TestCookieRoutesRefuseCrossSiteRequests,TestProductTokenAndStepUp); refresh reuse after the grace window ends the session (TestSessionLifecycle). - The internal API listens separately, is refused at the edge
(
/api/platform/internal/*→ 404 on every host), and compares product tokens in constant time. - Revocation: sessions ended on the Hub reach Axis with the next snapshot
(
TestSyncUnchangedAndRevokedSessions); disabled and removed users and organizations follow (TestSyncLifecycle). Lag: sync interval plus the 30 s principal cache. - The copy never hard-deletes (users →
deleted, tenants →suspended), so a mistaken change on the Hub loses no Axis data. - The Hub has its own access matrix over every route
(
entrosity-hub.backend/internal/http/rbac_matrix_integration_test.go).
gosec exclusions:
- G115: integer conversions of values that are validated and bounded.
- G204: the agent's job is to start programs; command lines are covered by item 8.
- G304: the agent reads its own state and cache files.
- Simulators and test fixtures (
agentsim,connectorsim,wingetfixture,testutil) are excluded; they are not shipped. - Individual
#nosecannotations carry their reason inline.
Automated results for this review (2026-09-24)
| Check | Result |
|---|---|
| govulncheck, all four modules (toolchain go1.26.8) | No called vulnerabilities (moby/go-archive upgraded to v0.3.0) |
| gosec with the exclusions above | 0 issues |
pnpm audit | No known vulnerabilities |
| gitleaks, full history | No leaks (development placeholders allowlisted in .gitleaks.toml) |
| Trivy, backend image | 0 HIGH/CRITICAL |
| Trivy, web image | 0 HIGH/CRITICAL (after the Caddy rebuild; the stock caddy:2.10/2.11 images had 59/17) |
Trivy config, deploy/ | 0 HIGH/CRITICAL (after running Caddy as non-root) |
| syft SBOM | backend image 90 packages, portal 554 components |
Sign-off
| Role | Name | Date | Result |
|---|---|---|---|
| Review and fixes | Claude (AI pair programmer), with an independent audit pass | 2026-09-24 | All items OK; 2 high and 3 medium findings fixed; remaining low risks listed above |
| Maintainer approval | pending |