CLI documentation

Reverse proxy the OpenCloud Security Scanner

Configure scanner reverse-proxy headers for nginx, Apache, Caddy, Traefik, and HAProxy.

Two different machines in this project sit behind a reverse proxy, and they want opposite things from it.

  • In front of an OpenCloud instance, the proxy is the thing this check grades. Most of the findings under headers are decided there, and a proxy that strips a header OpenCloud sent will cost an instance a grade it had earned. → In front of OpenCloud
  • In front of the scan service from this repository, the proxy decides whether the rate limit works at all, and whether a scan that takes a minute survives long enough to be read. → In front of the scan service

Both sections carry worked configuration for nginx, Apache httpd, Caddy, Traefik and HAProxy. Replace opencloud.example.com and scan.example.com with your own names; no real hostname appears anywhere in this repository.

In front of OpenCloud

OpenCloud's own proxy service already sends a full set of security headers. A finding under headers therefore almost always means one of two things: something in front of it removed them, or something in front of it answers before OpenCloud does. Adding them again in the proxy fixes both.

The headers this check looks for

Header What this check accepts
Strict-Transport-Security Any max-age. A year or more also passes hstsLongMaxAge, and preload passes hstsPreload
Content-Security-Policy Any non-empty policy. A policy containing unsafe-inline fails cspWithoutUnsafeInline separately
X-Content-Type-Options nosniff
X-Frame-Options SAMEORIGIN
X-Permitted-Cross-Domain-Policies none
X-Robots-Tag Any non-empty value
X-XSS-Protection Any non-empty value. Modern browsers ignore it; it is checked because OpenCloud sends it
Referrer-Policy Any non-empty value

Send them on the HTTPS listener only. Strict-Transport-Security on a plain HTTP response is ignored by browsers and tells an attacker nothing useful.

Two findings decided here that are not headers

Both are hardening flags rather than extra checks, so neither lowers the grade on its own - they raise the state to WARNING and are waivable with --ignore-hardening.

Flag What has to be true to pass
httpsEnforced A request to http:// on port 80 answers with a redirect whose Location starts with https:// - or port 80 does not answer at all
reverseProxyDetected Something in the response looks like a proxy: a proxy-style Server header, or any Via header

httpsEnforced is measured without following redirects: the scan asks port 80 for / once and reads the Location it gets back. A redirect to another plain-HTTP address, a 200 that serves the interface, and a redirect chain that reaches HTTPS only on its second hop all fail. A closed or filtered port 80 passes - plain HTTP cannot be spoken at all, which is the stronger version of enforcing it.

reverseProxyDetected is deliberately best-effort and never changes the rating, because Traefik and HAProxy announce nothing by default. A well-configured deployment can fail it with nothing wrong; treat it as a prompt to confirm there is something in front, not as a defect. If there is not, the configuration below is the usual way to put one there.

nginx

server {
    listen 443 ssl http2;
    server_name opencloud.example.com;

    ssl_certificate     /etc/ssl/opencloud/fullchain.pem;
    ssl_certificate_key /etc/ssl/opencloud/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;

    # always: also on 4xx and 5xx, which is where a missing header hides.
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Permitted-Cross-Domain-Policies "none" always;
    add_header X-Robots-Tag "noindex, nofollow" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    client_max_body_size 0;          # uploads are not the proxy's business
    proxy_request_buffering off;

    location / {
        proxy_pass http://127.0.0.1:9200;
        proxy_http_version 1.1;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $remote_addr;   # overwrite, never append
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade           $http_upgrade;
        proxy_set_header Connection        $connection_upgrade;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
}

server {
    listen 80;
    server_name opencloud.example.com;
    return 308 https://$host$request_uri;
}

add_header in nginx is not additive across levels: one add_header in a location discards every add_header from the server block. If you add one inside a location, repeat the whole set there.

Content-Security-Policy is deliberately absent above. OpenCloud sends its own, and a policy written by hand in the proxy is how an instance ends up with a broken web interface. Only set one here if the check reports it missing and you have established that nothing behind the proxy sends it.

Apache httpd

<VirtualHost *:443>
    ServerName opencloud.example.com

    SSLEngine on
    SSLCertificateFile      /etc/ssl/opencloud/fullchain.pem
    SSLCertificateKeyFile   /etc/ssl/opencloud/privkey.pem
    SSLProtocol             -all +TLSv1.2 +TLSv1.3

    # 'always' is the condition, not the flag: it covers error responses too.
    Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
    Header always set X-Content-Type-Options "nosniff"
    Header always set X-Frame-Options "SAMEORIGIN"
    Header always set X-Permitted-Cross-Domain-Policies "none"
    Header always set X-Robots-Tag "noindex, nofollow"
    Header always set X-XSS-Protection "1; mode=block"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"

    ProxyPreserveHost On
    ProxyPass        / http://127.0.0.1:9200/ timeout=3600
    ProxyPassReverse / http://127.0.0.1:9200/
    RequestHeader set X-Forwarded-Proto "https"
</VirtualHost>

Needs mod_headers, mod_proxy, mod_proxy_http and mod_ssl. Apache sets X-Forwarded-For itself and appends the client to it; do not also set it by hand, or the instance sees the address twice.

Caddy

opencloud.example.com {
    encode zstd gzip

    header {
        Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "SAMEORIGIN"
        X-Permitted-Cross-Domain-Policies "none"
        X-Robots-Tag "noindex, nofollow"
        X-XSS-Protection "1; mode=block"
        Referrer-Policy "strict-origin-when-cross-origin"
    }

    reverse_proxy 127.0.0.1:9200 {
        transport http {
            read_timeout 1h
        }
    }
}

Caddy terminates TLS and redirects port 80 by itself, and it sets X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host without being asked. Its header directive replaces a header the backend already sent, so this is safe to leave in place even once OpenCloud sends its own again.

Traefik

As labels on the OpenCloud container:

labels:
  - "traefik.enable=true"
  - "traefik.http.routers.opencloud.rule=Host(`opencloud.example.com`)"
  - "traefik.http.routers.opencloud.entrypoints=websecure"
  - "traefik.http.routers.opencloud.tls.certresolver=letsencrypt"
  - "traefik.http.services.opencloud.loadbalancer.server.port=9200"
  - "traefik.http.routers.opencloud.middlewares=opencloud-headers"
  - "traefik.http.middlewares.opencloud-headers.headers.stsSeconds=63072000"
  - "traefik.http.middlewares.opencloud-headers.headers.stsIncludeSubdomains=true"
  - "traefik.http.middlewares.opencloud-headers.headers.stsPreload=true"
  - "traefik.http.middlewares.opencloud-headers.headers.contentTypeNosniff=true"
  - "traefik.http.middlewares.opencloud-headers.headers.frameDeny=false"
  - "traefik.http.middlewares.opencloud-headers.headers.customFrameOptionsValue=SAMEORIGIN"
  - "traefik.http.middlewares.opencloud-headers.headers.referrerPolicy=strict-origin-when-cross-origin"
  - "traefik.http.middlewares.opencloud-headers.headers.customResponseHeaders.X-Permitted-Cross-Domain-Policies=none"
  - "traefik.http.middlewares.opencloud-headers.headers.customResponseHeaders.X-Robots-Tag=noindex, nofollow"
  - "traefik.http.middlewares.opencloud-headers.headers.customResponseHeaders.X-XSS-Protection=1; mode=block"

stsSeconds is the only way to get HSTS out of Traefik's headers middleware - setting Strict-Transport-Security through customResponseHeaders is overwritten. Traefik only sends HSTS on a TLS router, which is what you want.

HAProxy

frontend https-in
    bind :443 ssl crt /etc/ssl/opencloud/opencloud.pem alpn h2,http/1.1
    http-request redirect scheme https unless { ssl_fc }

    http-response set-header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
    http-response set-header X-Content-Type-Options "nosniff"
    http-response set-header X-Frame-Options "SAMEORIGIN"
    http-response set-header X-Permitted-Cross-Domain-Policies "none"
    http-response set-header X-Robots-Tag "noindex, nofollow"
    http-response set-header X-XSS-Protection "1; mode=block"
    http-response set-header Referrer-Policy "strict-origin-when-cross-origin"

    default_backend opencloud

backend opencloud
    option forwardfor
    http-request set-header X-Forwarded-Proto https
    timeout server 1h
    server oc1 127.0.0.1:9200 check

set-header replaces; add-header would append a second copy, and two Strict-Transport-Security headers are worse than none.

Mistakes that cost a grade

  • A header set only on 200. nginx's add_header without always, and Apache's Header set without always, both skip error responses. This check reads the headers of whatever the instance answers, so a redirect or a 401 without them is a finding.
  • A proxy that answers first. A maintenance page, an authentication gateway or a CDN error page is what gets scanned, and none of them look like OpenCloud. If the check reports the wrong product or no version at all, something in front is answering. See Troubleshooting.
  • X-Forwarded-For appended from a client-supplied value. Not a finding here, but it makes every rate limit and every audit log behind the proxy guesswork. Overwrite it at the edge.
  • X-Forwarded-Host passed through from the client. This one is a finding - forwardedHostIgnored - whenever the instance then builds its public URLs from it. The scan asks for the discovery document claiming a host that does not exist and looks for it in the issuer and the endpoints, because those are where the next sign-in is sent. Set OC_URL so the instance knows its own address, and set the header from the proxy's own configuration (proxy_set_header X-Forwarded-Host $host;) rather than forwarding whatever arrived. A default virtual host that refuses a name it does not recognise closes the same door for the Host header.
  • No default server. Without one, nginx answers a Host it has no server_name for from whichever server block it loaded first for that port - often a different application on the same machine - and Apache from the first <VirtualHost>. When that site redirects with $host, the probe host comes back in the Location, and forwardedHostIgnored fails with Host comes back as the address it redirects to although OpenCloud never saw the request and OC_URL is set correctly. A different certificate or a different set of headers on a request with a made-up Host is the tell:

bash curl -sI -H "Host: unknown.invalid" https://opencloud.example.com/.well-known/openid-configuration

Give the proxy an explicit default server that refuses every name it does not serve, and write redirects in other sites with their own name rather than $host:

nginx server { listen 80 default_server; listen [::]:80 default_server; listen 443 ssl default_server; listen [::]:443 ssl default_server; server_name _; ssl_reject_handshake on; # nginx 1.19.4 and later; no certificate needed return 444; }

In Apache, make the first <VirtualHost> for each port one that serves nothing (Redirect 403 /). Caddy and Traefik answer an unknown name without routing it to a site, so they need nothing here. - HTTP left open. A redirect is enough; this check follows it and grades the destination. What it will not forgive is a plain-HTTP listener that serves the interface as well - that is the httpsEnforced flag above. - A self-signed or expired certificate. The scan refuses to establish a version over an untrusted connection, and no header can make up for that.

In front of the scan service

The web application in this repository - docs/webapp.md - is a plain ASGI service on one port. It sends its own security headers, including a Content-Security-Policy with no unsafe-inline, so a proxy has nothing to add. What it does need is the truth about who is calling and enough patience for a scan to finish.

You do not have to copy any of this by hand. docker/setup-wizard.py writes the nginx, Apache, Caddy or Traefik configuration for a generated deployment, following the notes below and filling in the host name, the port and - where the stack brings its own identity provider - the forward auth in front of /admin and a site for Authentik itself at the host name of its public address. These sections are what it generates, and the reference for a deployment it did not write.

What the service needs from a proxy

  • A real client address. The rate limit and the target cooldown are the only things standing between a public scanner and being used as an amplifier, and both key off the client address. Pass X-Forwarded-For and set COS_WEB_TRUST_FORWARDED_FOR=true, plus COS_WEB_TRUSTED_PROXY_HOPS if more than one proxy of yours is in the path.

The header is read from the right, that many entries in, so a proxy that appends is as safe as one that overwrites - what a client sends stays to the left of the entry your own proxy wrote, and is never reached. Set the hop count to the number of proxies you operate: too few names a proxy instead of the visitor, which is harmless, while too many walks back into the part of the header the client controls. - Timeouts longer than a scan. A scan takes seconds to a minute; a PDF export and an MCP scan_instance call can hold a response for minutes. 120 seconds is a sensible floor and the MCP endpoint wants more. - No response buffering on /mcp. The Model Context Protocol endpoint answers with an event stream. A proxy that buffers it turns a working session into a client that waits for ever. - No rewriting of the discovery paths. /.well-known/ai.json, /openapi.json, /arazzo.json, /robots.txt and /sitemap.xml are served by the application and must reach it unchanged. A proxy that answers /.well-known/ itself - some ACME setups do - has to exclude that file. - The public origin, once. Set COS_WEB_PUBLIC_BASE_URL to the address visitors use. Canonical links, the sitemap and the absolute URLs in the discovery document are built from it, and behind a proxy the application cannot work it out on its own.

nginx

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 443 ssl http2;
    server_name scan.example.com;

    ssl_certificate     /etc/ssl/scan/fullchain.pem;
    ssl_certificate_key /etc/ssl/scan/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8811;
        proxy_http_version 1.1;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        # Overwrite. Appending would let a client choose its own rate limit.
        proxy_set_header X-Forwarded-For   $remote_addr;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_read_timeout 300s;
    }

    # The MCP endpoint streams. Buffering it breaks every agent session.
    location /mcp {
        proxy_pass http://127.0.0.1:8811;
        proxy_http_version 1.1;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For   $remote_addr;
        proxy_set_header Connection        "";
        proxy_buffering off;
        proxy_cache off;
        chunked_transfer_encoding off;
        proxy_read_timeout 3600s;
    }

    location /static/ {
        proxy_pass http://127.0.0.1:8811;
        proxy_cache_valid 200 1h;
        expires 1h;
    }
}

Then:

COS_WEB_TRUST_FORWARDED_FOR=true
COS_WEB_TRUSTED_PROXY_HOPS=1
COS_WEB_PUBLIC_BASE_URL=https://scan.example.com
COS_WEB_MCP_ALLOWED_HOSTS=scan.example.com

COS_WEB_MCP_ALLOWED_HOSTS is the MCP endpoint's DNS-rebinding protection. It is safe to leave empty behind a proxy that already fixes the host, and worth setting anyway - it costs nothing and it is one Host header away from being the only check.

Apache httpd

<VirtualHost *:443>
    ServerName scan.example.com

    SSLEngine on
    SSLCertificateFile    /etc/ssl/scan/fullchain.pem
    SSLCertificateKeyFile /etc/ssl/scan/privkey.pem

    ProxyPreserveHost On
    RequestHeader set X-Forwarded-Proto "https"

    # The client, and only the client. mod_remoteip first if you are behind
    # another proxy, so that %a is the address you actually want to forward.
    RequestHeader set X-Forwarded-For "%{REMOTE_ADDR}e"

    # The event stream must not be buffered or the session never starts.
    <Location "/mcp">
        ProxyPass        http://127.0.0.1:8811/mcp flushpackets=on timeout=3600
        ProxyPassReverse http://127.0.0.1:8811/mcp
        SetEnv proxy-sendchunked 1
        SetEnv no-gzip 1
    </Location>

    ProxyPass        / http://127.0.0.1:8811/ timeout=300
    ProxyPassReverse / http://127.0.0.1:8811/
</VirtualHost>

Order matters: the <Location "/mcp"> block has to come before the catch-all ProxyPass /, or the catch-all wins and the stream is buffered again.

Caddy

scan.example.com {
    encode zstd gzip

    # Streaming, and a timeout long enough for a scan to finish.
    reverse_proxy /mcp* 127.0.0.1:8811 {
        flush_interval -1
        transport http {
            read_timeout 1h
        }
    }

    reverse_proxy 127.0.0.1:8811 {
        transport http {
            read_timeout 5m
        }
    }
}

flush_interval -1 disables buffering, which is what the event stream needs. Caddy writes X-Forwarded-For from the connection and drops what the client sent, so COS_WEB_TRUST_FORWARDED_FOR=true is safe with the default COS_WEB_TRUSTED_PROXY_HOPS=1.

Traefik

labels:
  - "traefik.enable=true"
  - "traefik.http.routers.scan.rule=Host(`scan.example.com`)"
  - "traefik.http.routers.scan.entrypoints=websecure"
  - "traefik.http.routers.scan.tls.certresolver=letsencrypt"
  - "traefik.http.services.scan.loadbalancer.server.port=8811"
  # Do not buffer the MCP event stream; Traefik streams by default, so the
  # only thing to get right is the timeout on the entrypoint.
  - "traefik.http.services.scan.loadbalancer.responseForwarding.flushInterval=1ms"

with, on the static configuration:

entryPoints:
  websecure:
    address: ":443"
    transport:
      respondingTimeouts:
        readTimeout: 0        # a submission may hold the connection
        writeTimeout: 0       # a scan may take a minute, an MCP call longer
        idleTimeout: 300s

Traefik overwrites X-Real-Ip and appends to X-Forwarded-For. The application reads the header from the right, so the entry it takes is the one Traefik wrote and not whatever the client put in front of it: COS_WEB_TRUST_FORWARDED_FOR=true with the default COS_WEB_TRUSTED_PROXY_HOPS=1 is correct here. Behind a CDN as well, count both and set 2.

HAProxy

frontend scan-in
    bind :443 ssl crt /etc/ssl/scan/scan.pem alpn h2,http/1.1
    http-request set-header X-Forwarded-Proto https
    # set-header, not add-header: the client does not get a vote.
    http-request set-header X-Forwarded-For %[src]
    default_backend scan

backend scan
    option http-server-close
    no option http-buffer-request
    timeout server 1h
    timeout tunnel 1h
    server scan1 127.0.0.1:8811 check

timeout tunnel is what keeps the MCP stream alive; timeout server alone closes it mid-session.

Checking the result

# The client address the service actually sees, through the proxy.
curl -sS https://scan.example.com/healthz

# Discovery must be reachable, unauthenticated, and JSON.
curl -sS https://scan.example.com/.well-known/ai.json | head -c 200
curl -sSo /dev/null -w '%{http_code}\n' https://scan.example.com/openapi.json
curl -sSo /dev/null -w '%{http_code}\n' https://scan.example.com/arazzo.json

# The absolute URLs in the discovery document must be the public ones, not
# 127.0.0.1 - if they are wrong, COS_WEB_PUBLIC_BASE_URL is not set.
curl -sS https://scan.example.com/.well-known/ai.json | grep -o 'https://[^"]*' | head

# MCP: an initialise that answers is a proxy that is not buffering.
curl -sS -X POST https://scan.example.com/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
        "protocolVersion":"2025-06-18","capabilities":{},
        "clientInfo":{"name":"curl","version":"1"}}}'

The rate limit is the one thing worth testing from somewhere else: submit more scans than COS_WEB_IP_RATE_LIMIT allows from one machine and confirm the 429, then repeat from a second address and confirm it is not refused. If the second machine is limited too, the proxy is not passing the address on and every visitor is sharing one bucket.


This is an independent community project. It is not affiliated with, endorsed by or supported by OpenCloud GmbH. "OpenCloud" and all related marks belong to their respective owners and are used here only to identify the software this tool checks.