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 reads, and what it deliberately does not
- What the scanner checks
- Reading the version correctly
- Debug ports
- Every resolved address
What the scanner checks
Read from the instance itself:
- product,
productversionand edition from/status.php. Other products are refused because their releases and advisories do not match this scanner’s database. OpenCloud hardcodesmaintenance,installedandneedsDbUpgrade, 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
addressesin 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-ProtectionandReferrer-Policy, reported assetup.headers- seedocs/csp.mdfor what theContent-Security-Policychecks look for and why - four further headers that no OpenCloud sends -
Permissions-Policy,Cross-Origin-Opener-Policy,Cross-Origin-Resource-PolicyandCross-Origin-Embedder-Policy- reported separately assetup.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--debugand never counted as a missing hardening, never alerted on and never allowed to change an exit code. See ADR 0028. RehearseCross-Origin-Embedder-Policy: require-corpbefore rolling it out - an office integration that embeds Collabora or a WOPI host stops loading unless that origin sends aCross-Origin-Resource-Policyof its own - whether
/.well-known/security.txttells somebody who finds a flaw where to report it, assecurityTxtPublishedundersetup.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 theContactfield 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-Securityheader would actually be accepted for browser preloading, ashstsPreloadEligibleunder the samesetup.advisoryChecks.hstsPreloadalready reports whether the header asks to be preloaded; this reports whether asking could work, which needs a max-age of at least a year,includeSubDomainsandpreloadtogether. OpenCloud's own proxy sends ten years andpreloadbut noincludeSubDomains, 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 hardeningsderived 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/listis unprotected by OpenCloud's own proxy policy and names the app providers actually registered with the app registry - Collabora, OnlyOffice and the like. Theapp_providersblock in the capabilities document is hardcoded and says nothing, so it is not used./.well-known/caldavanswers 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).