Skip to main content

Security review (release 1.0)

info

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​

#ItemStatus
1Session cookie flagsOK (on the Hub since phase 7)
2CSP and security headersOK, fixed in 1.0 (files host)
3CORS origins from configOK, fixed in 1.0 (validation)
4Upload type/size allowlistOK, fixed in 1.0
5Path traversal on object keysOK
6SSRFOK
7LDAP injectionOK
8Command injection (agent/connector)OK, fixed in 1.0 (3 findings)
9Archive/MSI bombs, size limitsOK, fixed in 1.0 (push MSI hash)
10Secrets in logsOK, fixed in 1.0
11Agent key revocation on decommissionOK, fixed in 1.0 (other replicas, suspension)
12Enrollment token brute forceOK
13WebSocket origin checksOK
14SSE token leakageOK, fixed in 1.0 (POST /auth/sse-token)
15Tenant isolation (authz + RLS)OK
16Credentials, tokens, key rotationOK
17Supply chain (scanners, pinning, SBOM)OK
  • Since phase 7 the only session cookie is the Hub's platform_rt: HttpOnly, SameSite=Strict, Secure in production, with Path=/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:, and connect-src 'self' https://<files host> (browser package uploads go to presigned URLs);
  • frame-ancestors 'none', object-src 'none' and base-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>. SanitizeFileName takes 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_filter is written by the tenant admin by design. It is checked for balanced parentheses and length, so it stays inside the outer & (balancedFilter in entrosity-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_args was appended to the uninstall string and run through cmd.exe /c as 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 for cmd scripts (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/Basic values 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 Origin from 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 in app.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_KEY and 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_OLD verifies old stream tokens during a JWT secret rotation.
    • rmm-server rotate-master-key re-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.yml runs 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.yaml and image tags. Caddy is rebuilt from deploy/caddy with current dependencies, because upstream images lagged on Go and gRPC fixes. Go uses the patched toolchain go1.26.8.
  • Dependabot covers Go, npm, actions and Docker.
  • The images run as non-root: distroless nonroot for the backend; uid 10001 with only CAP_NET_BIND_SERVICE for Caddy.

18. Sign-in on Entrosity Hub (phase 7)​

  • Product tokens: EdDSA only (HS256 and alg: none refused), known kid, issuer RMM_PLATFORM_URL, audience rmm, no purpose, 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, jti recorded in platform_step_ups_used so 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 #nosec annotations carry their reason inline.

Automated results for this review (2026-09-24)​

CheckResult
govulncheck, all four modules (toolchain go1.26.8)No called vulnerabilities (moby/go-archive upgraded to v0.3.0)
gosec with the exclusions above0 issues
pnpm auditNo known vulnerabilities
gitleaks, full historyNo leaks (development placeholders allowlisted in .gitleaks.toml)
Trivy, backend image0 HIGH/CRITICAL
Trivy, web image0 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 SBOMbackend image 90 packages, portal 554 components

Sign-off​

RoleNameDateResult
Review and fixesClaude (AI pair programmer), with an independent audit pass2026-09-24All items OK; 2 high and 3 medium findings fixed; remaining low risks listed above
Maintainer approvalpending