CLI documentation

What the OpenCloud Security Scanner reads, and what it does not

Every endpoint read, every additional check and its severity, and the questions a scan from outside cannot answer.

The complete inventory of what a scan takes from an instance: the endpoints it reads, every additional check it runs and what each one is worth, which observations are recorded but never graded, and the questions a scan from outside cannot answer at all.

The main README summarises this in a paragraph; the individual checks are explained one group at a time in TLS, CSP, cookies, authentication, sharing, exposure, embedding and lifecycle.

What the scanner checks

Read from the instance itself:

  • product, productversion and edition from /status.php. Other products are refused because their releases and advisories do not match this scanner’s database. OpenCloud hardcodes maintenance, installed and needsDbUpgrade, so those fields are not treated as live health checks; see the status endpoint.
  • the IPv4 and IPv6 addresses the name resolved to while the scan ran, reported as addresses in the result document and shown as Resolved to on a web result page - context, never a finding, and empty when a name does not resolve or an address was scanned directly
  • capabilities from /ocs/v1.php/cloud/capabilities (both endpoints are unauthenticated in OpenCloud)
  • the security headers Strict-Transport-Security, Content-Security-Policy, X-Content-Type-Options, X-Frame-Options, X-Permitted-Cross-Domain-Policies, X-Robots-Tag, X-XSS-Protection and Referrer-Policy, reported as setup.headers - see docs/csp.md for what the Content-Security-Policy checks look for and why
  • four further headers that no OpenCloud sends - Permissions-Policy, Cross-Origin-Opener-Policy, Cross-Origin-Resource-Policy and Cross-Origin-Embedder-Policy - reported separately as setup.advisoryHeaders. A reverse proxy can add all four and the instance is better for it, but their absence is the shipped state of every OpenCloud rather than a fact about this deployment, so they are explained by --debug and never counted as a missing hardening, never alerted on and never allowed to change an exit code. See ADR 0028. Rehearse Cross-Origin-Embedder-Policy: require-corp before rolling it out - an office integration that embeds Collabora or a WOPI host stops loading unless that origin sends a Cross-Origin-Resource-Policy of its own
  • whether /.well-known/security.txt tells somebody who finds a flaw where to report it, as securityTxtPublished under setup.advisoryChecks. OpenCloud does not publish this file by default. As with the advisory headers above, the result explains its absence without affecting the grade. The file has to carry the Contact field RFC 9116 requires - a 200 alone means nothing on an instance whose frontend answers every unknown path with its own shell. See ADR 0034
  • whether the Strict-Transport-Security header would actually be accepted for browser preloading, as hstsPreloadEligible under the same setup.advisoryChecks. hstsPreload already reports whether the header asks to be preloaded; this reports whether asking could work, which needs a max-age of at least a year, includeSubDomains and preload together. OpenCloud's own proxy sends ten years and preload but no includeSubDomains, so every stock instance asks for something the list refuses - a fact about OpenCloud rather than about the deployment, which is why it is explained and never counted. Membership of the list itself is deliberately not measured: the only ways to know are to ask a third party for it or to ship tens of megabytes of it. See ADR 0037
  • hardenings derived from those headers and capabilities
  • known vulnerabilities from the advisory database and the resulting rating (0-5)

Plus the additional checks (extraChecks in the JSON, disable with --no-extra-checks):

Check Severity Purpose
httpsAvailable, tlsHandshake, tlsProtocol critical/high Instance only reachable over HTTP, broken TLS, or a protocol older than TLS 1.2
tlsCertificate, tlsTrusted high/medium Certificate expired, expiring within scanner.tls_min_days, or not trusted
tlsDeprecatedProtocol high The server still accepts TLS 1.0 or 1.1 even though it negotiated something newer with us
tlsHostname high The certificate does not cover the name it was asked for
tlsChain medium The chain is missing an intermediate, so it validates only for clients that happen to have one cached
tlsCertificateLifetime low The certificate is valid for longer than the scanner’s 398-day threshold
tlsCipherSuite medium The cipher suite negotiated by this scan is weak or lacks forward secrecy
tlsCertificatePolicy medium The certificate has a weak key or an MD5/SHA-1 signature
tlsAddressParity medium IPv4 and IPv6 present different TLS services, or one is unreachable
addressParity high/medium With --all-addresses: the resolved addresses serve a different release, headers, hardening or demo-account state, or one does not answer
tlsCaaRecord low No DNS CAA record restricts which certificate authorities may issue for this name
tlsDnssec low The zone answering for this name is not signed, so a forged address cannot be detected - absent, never failed, when the resolver used does not speak DNSSEC
companionAdminConsole high A collaboration backend published on this origin answers on its administration console path
companionEditorHttps high That backend advertises editor addresses over plain HTTP in its WOPI discovery document
cookieSecure, cookieHttpOnly, cookieSameSite high - low An observed cookie lacks Secure, HttpOnly or SameSite
cookiePrefix low No observed cookie uses the __Host-/__Secure- name prefix, or one claims a prefix it does not honour
tlsOcspStapling low No OCSP response stapled to the handshake, although the certificate names a responder
tlsCertificateTransparency medium A publicly trusted certificate carries no embedded signed certificate timestamps
tlsEarlyData low The server's session tickets invite a TLS 1.3 0-RTT flight, which has no replay protection
corsOriginRestricted critical/medium Any origin may read the API's responses; critical when credentials are allowed with it
traceMethodDisabled medium The server answers TRACE by echoing the request
forwardedHostIgnored medium A host name the caller supplied comes back in the discovery document, so a caller chooses where a sign-in goes
header:<name> high - low One of the headers above missing or too weak
authentication:/remote.php/dav/files/, /graph/v1.0/users, /ocs/v1.php/cloud/user critical/high An endpoint that must demand authentication answered anyway
exposed:/opencloud.yaml, /proxy/server.key, /idm/opencloud.boltdb, /.env, /docker-compose.yml, /storage/users/, /.git/config critical/high Deployment internals published by a misconfigured reverse proxy
directoryListing critical A directory index served instead of the web frontend
demoUsersDisabled critical The built-in identity provider still accepts the documented demo accounts, one of which is an administrator
debugEndpoint:/metrics, /config, /debug/pprof/ critical/high Debug handlers reachable on the public address
debugPort:<port> high A service debug port answering from the outside
backendPortClosed high The same OpenCloud instance is reachable directly on backend port 9200, bypassing its reverse proxy
webEmbedDelegatedAuthenticationRestricted critical Delegated iframe authentication accepts messages without an explicit trusted origin
webEmbedMessageOriginRestricted high The web client's embed messages trust every parent origin
basicAuthDisabled medium The proxy still offers HTTP basic authentication
identityProviderDetected low No OpenID Connect discovery document and no redirect from it, so who signs users in cannot be established
reverseProxyDetected low Nothing suggests a reverse proxy in front of the instance
versionDisclosure:Server, webfingerVersionDisclosure low Exact versions leaked to unauthenticated callers

A failed additional check caps the rating (critical -> D, high -> C, medium -> A, low -> A+); set scanner.extra_checks_rating: false to report them without touching the rating. For the reasoning behind each group of checks above, see docs/cookies.md, docs/authentication.md, docs/sharing.md, docs/exposure.md, docs/embedding.md and docs/lifecycle.md, alongside docs/csp.md and docs/tls.md above.

OpenCloud is a single Go binary that serves its web frontend from embedded assets, and its frontend is a single-page application: unknown paths return the app shell with HTTP 200 rather than a 404. A naive "does /opencloud.yaml return 200?" check would therefore flag every healthy instance. The scanner first requests a path that cannot exist and records the catch-all response. It only reports an exposed path when its response differs from that baseline.

Who signs users in

The scan also reads /.well-known/openid-configuration - the OpenID Connect discovery document, or the redirect the instance answers it with - to find out which identity provider issues its tokens. An issuer on a different host means an external provider such as Keycloak, Authentik or Authelia is in front of the instance, and the result document records it:

{"identityProvider": {"detected": true, "external": true,
                      "issuer": "https://id.example.com", "vendor": "Keycloak"}}

This is context, never a verdict: using the built-in provider fails nothing, and no check requires an external one. It only softens basicAuthDisabled, which is medium normally and low when the interactive login goes through an external provider.

Provider detection reads the discovery document and its Location header without submitting a login. The separate demo-account check below is the only probe that sends credentials.

When no provider can be found at all, identityProviderDetected fails at severity low and --debug points at OpenCloud's own documentation - the usual cause is a reverse proxy that does not forward /.well-known/.

The demo accounts

When the discovery document names the instance's own provider - the built-in identity management rather than a Keycloak or Authentik in front of it - the scan additionally checks whether the demo users are still on. IDM_CREATE_DEMO_USERS=true creates five accounts whose names and passwords are printed in OpenCloud's documentation, and dennis is an administrator. Left enabled on a reachable instance, that is an admin account whose password everybody already knows, so demoUsersDisabled is a critical finding: it fails the check and caps the rating at D.

This is the one place the scan sends a credential, and it does so because there is no other way to see those accounts from outside - nothing OpenCloud exposes unauthenticated lists its users. What is sent is a published default rather than a guess at anybody's password, only the documented pairs are tried, and they go only to the instance's own provider: with an external identity provider the accounts come from there, the check does not apply, and no login is ever pushed at a third party. Switching the setting off does not delete accounts that already exist, so a failing instance needs them removed as well.

What is in front of the instance

reverseProxy records whether anything answers before OpenCloud does: a Server header naming Nginx, Caddy, Cloudflare or another proxy, or a header only a forwarder adds such as Via.

{"reverseProxy": {"detected": true, "vendor": "Nginx", "evidence": "Server: nginx"}}

reverseProxyDetected fails when nothing was found, and does so at severity low on purpose: Traefik and HAProxy announce nothing by default, and stripping the Server header is itself good practice, so a well-run deployment can look bare from outside. The finding is worth showing and is never worth a grade.

forwardedHostIgnored asks the other question about the same boundary: not whether something is in front, but whether the instance lets whoever is calling decide what it thinks its own address is. The scan requests /.well-known/openid-configuration twice with a host that does not exist - once as the request's own Host, once as X-Forwarded-Host - and looks for that host coming back in the Location it redirects to or in the issuer, authorization_endpoint, token_endpoint, end_session_endpoint or jwks_uri the document publishes.

{"id": "forwardedHostIgnored", "severity": "medium", "passed": false,
 "detail": "A host name the caller supplied is published back: X-Forwarded-Host comes back as the issuer it publishes"}

These URLs direct authentication requests. A caller-controlled hostname initially affects that caller’s response, which is why the finding is medium. A shared cache or a proxy forwarding untrusted X-Forwarded-Host values can extend the effect to other users. Set OC_URL and have the proxy supply forwarded headers from its own configuration.

When only Host comes back, as the address it redirects to, look at the proxy before the instance: with no default server, a name the proxy has no site for is answered by whichever site it loaded first for that port, and a redirect there built from $host repeats the probe host whatever OC_URL says. An explicit default server that refuses unknown names fixes it - see No default server.

Only a URL a client would be sent to counts. A default virtual host that refuses an unrecognised name commonly prints that name in its error page, and reading the body for it would report the correct behaviour as the finding. An instance that publishes no discovery document at all is not judged either way: two errors are the scan learning nothing, not a pass.

Alternative services (HTTP/3)

alternativeServices records what the instance advertises in its Alt-Svc header. An h3 entry tells every browser to try HTTP/3 over UDP on the named port - a listener a firewall written for TCP 443 may not cover, and one a reverse proxy can enable without anybody deciding to.

{"alternativeServices": {"advertised": true, "http3": true,
  "entries": [{"protocol": "h3", "host": "", "port": 443, "udp": true}],
  "header": "h3=\":443\"; ma=86400"}}

It is an observation and is never graded: HTTP/3 is not a weakness, only something to firewall on purpose. The plugin prints a detail line when it sees one. The advertised address is never probed - it is the target's word, not an origin the scan was pointed at (ADR 0036). Alt-Svc: clear records nothing as advertised, and without a response to read the header from the key is null.

Failed sign-ins (opt-in)

With --login-throttling (COS_LOGIN_THROTTLING, or scanner.check_login_throttling) the scan sends six failed sign-ins, one after another, for a random account that cannot exist, and records whether the instance slowed them down - an HTTP 429 or a Retry-After header:

{"loginThrottling": {"tested": true, "attempts": 4, "throttled": true,
  "evidence": "HTTP 429, Retry-After: 30", "statuses": [401, 401, 401, 429]}}

It is off by default, asks only the built-in identity provider, runs after every other probe so it cannot hide the demo accounts behind a 429, and is never graded: many deployments throttle over a longer window or at a layer a short burst does not reach, so "not throttled" is a prompt to look, not a verdict. The public web service never sends it (ADR 0069). Without the option the key is null.

Office and calendar integrations

Two integrations are visible without logging in, and both are reported as observations rather than verdicts:

  • /app/list is unprotected by OpenCloud's own proxy policy and names the app providers actually registered with the app registry - Collabora, OnlyOffice and the like. The app_providers block in the capabilities document is hardcoded and says nothing, so it is not used.
  • /.well-known/caldav answers with a redirect or an authentication challenge only when something is wired to it, which is how a proxied Radicale shows up. A stock instance answers 404.
{"integrations": {"office": {"detected": true, "apps": ["Collabora"], "groupware": false},
                  "calendar": {"detected": true, "advertised": true}}}

Neither becomes a check and neither can move the rating.

What the deployment publishes is a separate question, and it does become a check. Where a reverse proxy serves the collaboration backend on the instance's own origin, /hosting/discovery answers with the document the WOPI protocol specifies, and two findings follow from it: whether the editor's administration console is reachable (companionAdminConsole), and whether the editor addresses it advertises use HTTPS (companionEditorHttps).

The scanner probes only the submitted origin. It does not follow an editor hostname from the discovery document, because that would let the target choose another connection destination. A separately hosted editor therefore gets no finding from these checks. Assess that service with suitable editor-specific tools; see ADR 0036.

What the scan deliberately does not answer

  • Audit logging. OpenCloud's audit service only consumes the internal event bus. It publishes no endpoint, and no unauthenticated document mentions it, so whether it is enabled cannot be established from outside at all. It is not checked, and a clean report says nothing about it.
  • Whether an integration is configured correctly. The scan reports that an app provider is registered, or that something answers the CalDAV path. WOPI secrets, share permissions and the other service's own configuration live behind a login and are not checked.
  • Anything requiring credentials. No login form is ever submitted and no password is ever guessed. The single exception is the demo accounts above: the passwords OpenCloud publishes are sent, as published, to the instance's own identity provider, because that is the only way to see from outside whether those accounts still exist.
  • Your firewall, your identity provider's policy, your backups. All of it matters more than several of the things above, and none of it is visible over HTTP.

Running OpenCloud in a secure infrastructure covers these separate operational checks: identity-provider policies, audit logging, firewall rules, user guidance and scheduled monitoring.

Reading the version correctly

/status.php reports three version fields, and two of them are traps:

{"version":"0.1.0.0","versionstring":"0.1.0","productversion":"7.4.0"}

version and versionstring are compatibility values. The actual release is productversion. The scanner prefers that field, falls back to capabilities and sets legacyVersion: true if only a placeholder is available. Check which field your own monitoring scripts read as well.

Debug ports

Every OpenCloud service has a debug listener that serves /healthz, /readyz, /metrics, /config and /debug/pprof. /metrics includes opencloud_proxy_build_info (exact version), /config dumps the effective service configuration, and /debug/pprof allows anyone to trigger profiling.

These listeners bind to loopback by default, so a debug port that answers from your monitoring host is a genuine finding - usually a container that published the whole port range. The scanner probes the five most informative ones:

Port Service
9205 proxy
9141 frontend
9124 graph
9134 idp
9239 idm

Each probe is a single TCP connect with a three second timeout, so a firewalled host costs up to 15 seconds. Turn the probes off with --no-debug-ports, run them in parallel with --concurrency, or tune them:

scanner:
  check_debug_ports: true
  debug_ports: [9205, 9141]
  debug_port_timeout: 1

Speeding the scan up

A scan spends nearly all of its time waiting for the instance to answer: around twenty HTTP requests and five TCP connects, one after the other. scanner.concurrency runs those probes in parallel for a single-host scan; raising it shortens a run considerably, at the price of a burst of parallel requests against the instance, and is most noticeable when debug-port probing runs into a firewall that swallows the connections. --concurrency instead controls the outer host-worker ceiling described in Checking multiple hosts.

The setting changes only the timing, never the verdict: the result document lists the same findings in the same order whatever the value is. Values above 32 are clamped. It can also be set once for every host:

scanner:
  concurrency: 8

Every resolved address

A scan dials the name once and sees whichever address the resolver put first. For a name behind a pool of nodes that is one node, and the node that missed a configuration rollout - no HSTS, demo accounts still signing in, an older release - is invisible. tlsAddressParity compares only the TLS identity of one IPv4 and one IPv6 address, which several nodes behind one certificate share whatever they serve.

--all-addresses (COS_ALL_ADDRESSES, scanner.check_all_addresses) repeats the part of the scan a rollout changes against each resolved address, one after another:

  • the release in /status.php (or the capabilities document),
  • the graded security headers, compared by verdict rather than value, so a CSP nonce is not a difference,
  • the hardening measures read from the root page, capabilities, the authentication challenge and the identity provider,
  • whether a documented demo account signs in.

What the nodes share - certificate chain, CAA, DNSSEC, debug ports - is not asked again. Each request keeps the hostname in Host and SNI; only the address the connection goes to changes, and the addresses are the resolver's answer for that name, never anything the instance said. IPv6 addresses are skipped when scanner.ipv6_enabled is off.

The result is addressParity, with the first address as the reference:

Difference on another address Severity
A demo account signs in where it did not as demoUsersDisabled on its own
A different release high
A header or hardening measure passes/fails medium
The address resolves but does not answer medium

Waived headers and checks are not compared. A name with one address gets no finding at all - an absence, not a pass - and no extra requests. What each address served is in the result document as addressObservations.

It is off by default: about a dozen requests per address, a demo sign-in among them. It sees what DNS sees - nodes behind a single load-balancer address, a resolver returning a rotating subset, or GeoDNS answering for the monitoring host's location all limit what can be compared. The public web service never runs it (ADR 0042).