Skip to content

Configuration ​

Deployment configuration lives in .env in the project root. Docker Compose reads it to fill the environment: entries of docker-compose.yml (and of compose.prod.yml on an installer host); a variable reaches a container only if the compose file passes it to that service. Everything else (the lists, filtering, inspection, alerts, retention) is a setting stored in the database and edited in the UI under Settings.

Authentication (required) ​

VariableServiceDescription
BASIC_AUTH_USERNAMEbackendAdmin user name. Required
BASIC_AUTH_PASSWORDbackendAdmin password. It is stored as a bcrypt hash when the user is first created; after that the stored hash is what counts, and the password is changed in the UI, not here. Required: the backend exits if it is empty, shorter than 8 characters, or one of changeme, password, admin, secret, test, 12345678, qwerty (any case)
SECRET_KEYbackendHMAC key for the JWTs. Empty: generated once and kept in data/.jwt_secret; a file there shorter than 32 characters is moved to data/.jwt_secret.rejected-<time> and a new secret generated, which ends every session. Set: at least 32 characters, at least 8 distinct characters, not a known example value, or the backend exits
INTERNAL_ALERT_TOKENbackend, wafService credential between the two. The WAF sends it on POST /api/internal/alert; the backend sends it to the WAF management API (statistics, category and heuristic toggles, reset). Generate with openssl rand -hex 32. Empty: the backend generates one at first start into the service-token volume (/var/lib/spm-service-token/alert_token, SPM_SERVICE_TOKEN_FILE to move it), mounted read-write in the backend and read-only in the WAF only, and the WAF reads it from there. Without that volume an empty value leaves block alerts dropped and the backend's management calls refused

BASIC_AUTH_* is the dashboard and API login and reaches only the backend. The proxy login devices use, when Require proxy login is on, has its own username and password, set in Settings > Clients and access (see Security: proxy login).

Web UI and TLS ​

VariableDefaultDescription
CORS_ALLOWED_ORIGINShttps://localhost:8443Comma-separated origins the backend names in CORS responses and accepts for the WebSocket (a WebSocket from the same host name as the page is accepted too). * is dropped
LETSENCRYPT_DOMAIN, LETSENCRYPT_EMAILemptyRead by the web entrypoint, which tries certbot. The shipped image has no certbot, so with both set it logs the reason and falls back to a mounted or self-signed certificate. To use a CA certificate, mount it at /etc/nginx/ssl/server.crt and /etc/nginx/ssl/server.key, or terminate TLS on a reverse proxy in front
BACKEND_URLhttp://backend:5000Where nginx sends /api/
REQUEST_TIMEOUT120nginx read and send timeout for /api/, in seconds

Proxy and network ​

VariableDefaultDescription
PROXY_BIND_IP0.0.0.0Host address port 3128 is published on. 127.0.0.1 limits it to the host
GUI_IP_WHITELISTemptyExtra client source addresses or CIDRs (comma or space separated) allowed to use the proxy, beyond private IPv4 ranges, IPv6 unique-local and link-local addresses and localhost. Listed sources follow the same rules as LAN clients. Invalid entries are skipped
PROXY_HOSTproxyHost name of the proxy container; the backend counts its address as the stack's own traffic, not a client
PROXY_IPemptyWhen set, dnsmasq answers wpad, wpad.local and wpad.lan with this address. dnsmasq is used only by Squid, so LAN clients see these records only if your own DNS serves them too. The PAC file itself is at http://<host>/wpad.dat whatever this is set to
PAC_FALLBACK_DIRECTfalseRead by the web entrypoint. false: the PAC file sends everything that is not local to the proxy and nothing else, so a device does not go around the filtering while the proxy is down. true: adds ; DIRECT, so devices connect directly, unfiltered, when the proxy does not answer. Any other value stops the container. A browser that cannot fetch the PAC file at all connects directly either way
WAF_FAIL_OPEN0Passed to the proxy and the WAF. 1 sets Squid's REQMOD bypass=1, so requests pass uninspected while the WAF is unreachable, and lets the WAF allow traffic on an internal REQMOD panic. 0 fails closed
SPM_SQUID_DOMAIN_ACL_MAX250000Proxy. Squid denies the blocked domains itself while the list has at most this many names; above it only the DNS sinkhole applies
SPM_DNS_BLOCKLIST_MAX_DOMAINS1000000Backend. Most entries the domain blacklist may hold (see Size limit)

PROXY_PORT and PROXY_CONTAINER_NAME are passed to the backend, which does not read them. The proxy port is fixed at 3128 by the port mapping and the health check in the compose file.

WAF ​

VariableDefaultDescription
WAF_ENGINEhybridRequest engine. hybrid runs the built-in rules and then the OWASP Core Rule Set at paranoia level 1; legacy runs the built-in rules alone, as before. Any other value, or a Core Rule Set that fails to load, stops the WAF with the reason in its log
WAF_REGEX_ENGINEgoThe regular expression engine the rules run on. go is Go's regexp; re2 is RE2 compiled to WebAssembly and run in-process (wazero), for the built-in rules and the Core Rule Set's rx operator, with its pm, detectSQLi and detectXSS operators on the same runtime. re2 read the measured request bodies 1.8 to 4.2 times faster and uses about 60 MB more memory; it is an option until it has run on real traffic with the same verdicts. Any other value stops the WAF
WAF_REGEX_SHADOW01 runs every rule match on the other regex engine too, for comparison only: the verdict stays the one of WAF_REGEX_ENGINE. A different answer is counted in waf_regex_shadow_disagreements_total by rule (crs-rx:<hash> for a Core Rule Set pattern) and logged with the rule, the two answers, the input's length, whether it is valid UTF-8 and a prefix of its SHA-256, never its content; the log is capped at 20 lines a minute. It covers the built-in rules and the Core Rule Set's rx, not pm, detectSQLi and detectXSS. Costs about 60 MB of memory and the regex time of the second engine
WAF_OBSERVE_CATEGORIESemptyComma-separated rule categories that match and are logged but never add to the score. Empty takes the engine's default: DATA_LEAK_PREVENTION,FINANCIAL_DATA,CLOUD_SECRETS with hybrid, none with legacy. none makes every category score
WAF_BLOCK_THRESHOLD10Anomaly score of the built-in rules at or above which a request is blocked. A value that is not a positive integer is ignored with a warning. The Core Rule Set keeps its own threshold of 5
WAF_DISABLED_CATEGORIESemptyComma-separated built-in rule categories switched off at start, for example PROTOCOL_ANOMALY,FINANCIAL_DATA. GET /api/waf/categories lists the names. Like the runtime category toggles, it does not switch the Core Rule Set: with WAF_ENGINE=hybrid an SQL injection is still blocked with SQL_INJECTION off
WAF_H_ENTROPY, WAF_H_BEACONING, WAF_H_PII, WAF_H_SHARDING, WAF_H_GHOSTING1Heuristics at WAF start (1/0, true/false, yes/no, on/off)
WAF_H_SEQUENCE0Sequence validation heuristic at WAF start
WAF_H_ENTROPY_MAX7.5Entropy above which the entropy heuristic scores
WAF_TRAFFIC_LOG_PATH/var/lib/waf/waf_traffic.jsonlThe WAF's per-request feature log, on the waf-traffic volume
WAF_POLICY_FILE/config/waf_policy.jsonLog-only settings and rule exceptions, re-read while the WAF runs (see WAF policy). A missing file is an empty policy

The heuristics are also settings (Settings > Inspection). With INTERNAL_ALERT_TOKEN set, the backend pushes the stored values to the WAF at start and every 30 seconds until the WAF matches them, so the stored settings win over the WAF_H_* values after start.

DNS ​

VariableDefaultDescription
DNS_UPSTREAM_11.1.1.3First upstream resolver
DNS_UPSTREAM_29.9.9.9Second upstream resolver
DNS_UPSTREAM_38.8.8.8Third upstream resolver
SAFESEARCH_REFRESH_SECONDS3600With SafeSearch or YouTube Restricted Mode on, how often their enforcement hosts are resolved again; dnsmasq restarts only when an address in use changed

Optional services ​

VariableDefaultDescription
TS_AUTHKEYemptyAuth key for the tailscale service, started only with docker compose --profile tailscale up -d
TAILSCALE_HOSTNAMEsecure-proxyHost name of the tailscale container
GRAFANA_ADMIN_PASSWORDemptyGrafana in the observability profile refuses to start without it (see Getting Started)
SPM_IMAGE_TAGthe releaseProduction compose only: the image tag to pull
COMPOSE_FILESet to compose.prod.yml by the installer

Backend variables the compose files do not pass ​

The backend reads these, with the defaults shown, but the shipped compose files do not pass them. To use one, add it to the backend's environment: list.

VariableDefaultDescription
JWT_EXPIRE_DURATION8hAccess token lifetime (Go duration). Refresh tokens end 7 days after the login whatever this is
MAX_LOGIN_ATTEMPTS5Failed logins per client address within the window
RATE_LIMIT_WINDOW_SECONDS300The failed-login window
ENCRYPTION_KEYgenerated64 hex characters. Empty: generated once and kept in data/.enc_key. Encrypts the alert channel secrets. A data/.enc_key that exists but is not 64 hex characters stops the backend with an error naming the file; it is never replaced, because it may be the only key that decrypts the stored secrets
UPDATE_CHECKtruefalse: the backend never asks api.github.com for the newest release. By default it does 30 seconds after start and every 6 hours, to show an update in the sidebar and raise an update_available alert. A value that is not true or false stops the backend
GEOIP_URLemptyReplaces the country feed: <GEOIP_URL>?cc=<code> is fetched instead of ipdeny.com and GitHub
WAF_SERVICE_USERNAME, WAF_SERVICE_PASSWORDBASIC_AUTH_*Basic credential for the WAF management API, used only when INTERNAL_ALERT_TOKEN is empty
LOG_FORMATJSONpretty for console-formatted logs

DATABASE_PATH (/data/proxy_manager.db), CONFIG_DIR (/config), LOG_PATH (/logs/access.log), DNS_LOG_PATH (/logs/dnsmasq.log), PORT (5000), WAF_URL (http://waf:8080) and PROXY_URL (http://proxy:3128) describe the container layout; leave them alone unless you change the mounts.

Squid configuration ​

proxy/generate_squid_conf.sh writes squid.conf at container start and on every reload. The access policy, the ICAP wiring and the log format are always generated. Extra directives go in:

/config/custom_squid_extra.conf

The file is appended to the generated configuration. A legacy /config/custom_squid.conf is renamed to custom_squid_extra.conf when no extras file exists. A file that defines http_port, https_port, icap_service, cache_dir or access_log is treated as a full configuration and not appended; the proxy logs a warning instead. If the configuration does not parse at boot, the proxy regenerates it once without the extras file.

WARNING

Extras come after the generated access policy, which ends in http_access deny all. Squid stops at the first http_access rule that matches, so an http_access line in this file is never evaluated. Use it for tuning directives, and check it with docker compose exec proxy squid -k parse.

Squid values the settings control ​

Squid directiveValue
cache_memSettings > Proxy engine > Memory cache (MB), stored default 256
cache_dir ufs /var/spool/squidSettings > Proxy engine > Disk cache (MB), stored default 2000
maximum_object_size100 MB
request_body_max_size10 MB
connect_timeout, dns_timeout30 seconds, 5 seconds
positive_dns_ttl1 minute

Every save in Settings rewrites /config/squid_settings.env; saving a section that holds proxy settings also reloads the proxy. The generator also reads PROXY_CACHE_SIZE_MB and PROXY_MEMORY_CACHE_MB, but the compose files do not pass them to the proxy.

Default-deny egress (optional) ​

By default the proxy is a deny-list gateway: clients reach any destination that is not blocked. Default-deny egress lets local clients reach only the entries on the Default-deny allowlist, LAN destinations (10.0.0.0/8, 192.168.0.0/16) and IP exceptions; everything else gets a Squid 403.

It is off by default and set in the UI, not in .env:

  • Turn on Settings > Filtering > Only allowlisted destinations (the egress_default_deny setting). The page refuses to save it while the allowlist is empty.
  • Manage the destinations on the Default-deny allowlist page. An entry is an address, a CIDR range or a domain. A domain entry matches that exact name; with Include subdomains (include_subdomains in the API, stored as .example.com) it covers the domain and every name under it; *.example.com is refused.

See Lists for the API.

Client IP masking (gdpr_mode) ​

Settings > Logs and privacy > Mask client IPs (gdpr_mode) masks client IP addresses across all API responses (GET /api/logs, GET /api/logs/export, /api/ws/logs, GET /api/clients/statistics, GET /api/clients/{ip}/details, GET /api/dashboard/summary), the MCP server and outbound alert notifications (webhooks, Gotify, Telegram, ntfy, Teams and alert digests). IPv4 addresses are masked to /24 (192.168.1.x) and IPv6 addresses to /64 (2001:db8:1:2::x). In GET /api/clients/statistics and GET /api/dashboard/summary, clients are grouped by their /24 or /64 network CIDR (network, addresses), and GET /api/clients/{ip}/details and source_ip filters on GET /api/logs and GET /api/logs/export accept the network CIDR while refusing single addresses with 400. The SQLite database and GET /api/database/export keep the full unmasked client addresses.

WAF custom rules ​

Put one regular expression (Go RE2 syntax) per line in /config/waf_custom_rules.txt. Lines starting with # are comments. A line longer than 512 characters, containing a NUL byte, that does not compile, or that matches the empty string or every one of four benign samples is skipped with a log line. The rules are read when the WAF starts:

bash
docker compose restart waf

Each custom rule is matched case-insensitively and adds 7 to the anomaly score. With the default threshold of 10, one custom rule match alone does not block a request; it blocks when another rule adds at least 3, or when WAF_BLOCK_THRESHOLD is 7 or lower.

# Raise the score of requests that mention an internal codename
project-codename
pre-release-build

WAF policy: log-only and exceptions ​

A false positive can be switched off for one rule, on one host, path or argument, without switching off the rule's category and without restarting the WAF. The policy is a JSON file, /config/waf_policy.json by default (WAF_POLICY_FILE):

json
{
  "version": 1,
  "mode": "block",
  "observe_rules": ["CRS-942290"],
  "observe_categories": ["CRS_RCE"],
  "exceptions": [
    { "rule": "CRS-942290", "host": "shop.example", "target": "ARGS:password", "comment": "passwords with $ in them" },
    { "rule": "CRS-930120", "host": ".ads.example", "target": "ARGS_NAMES:/^ep\\./" },
    { "rule": "SQLi-015", "host": "search.example", "path": "/api/" }
  ]
}
FieldMeaning
version1
modeblock (the default) or observe: the whole WAF decides as usual and blocks nothing. The traffic log marks the requests it would have blocked (would_block) and waf_would_block_total counts them
observe_rulesRules that match and are logged with no score. A Core Rule Set rule still runs, and what it added to the anomaly score is taken off before the decision
observe_categoriesThe same for a whole category: a built-in one (SSRF) or a Core Rule Set family (CRS_RCE)
exceptionsEach takes one rule off the requests it matches. rule is the ID as the WAF reports it: CRS-942290 for the Core Rule Set, SQLi-015 for a built-in rule. At least one of host, path and target is required
hostA host name, or with a leading dot the name and every name under it. Matched without the port and without regard to case
pathA prefix of the URL path, starting with /
targetCore Rule Set rules only: the variable, or one key of it, taken out of the rule: ARGS:password, REQUEST_HEADERS:Accept, REQUEST_FILENAME. A key between slashes is a regular expression, matched against the key in lowercase
commentFree text

A field the format does not have, an unknown built-in rule ID, a host with a port or a wildcard, or an exception with only a rule is an error. At start an invalid file stops the WAF with the reason in its log. While the WAF runs, the file is checked every 5 seconds; POST /policy/reload on the WAF management port (behind the same credential as the other management routes) reads it at once and answers 422 with the reason when it is invalid. An invalid file leaves the policy in force: the reason is in the WAF log and in GET /policy, /health says "reload_failed": true, and waf_policy_reloads_total{result="error"} counts it. A valid change empties the safe-URL cache and changes the ICAP ISTag, so Squid drops the verdicts it cached under the old policy.

Built-in rule matches an exception took off are listed in the traffic log (waf_excepted) and counted by waf_exception_hits_total. A Core Rule Set rule taken off does not run, so it is not counted.

In the web UI, a WAF block in the access log shows the rules that added to its score, each with an exception for the host, the path or, for a Core Rule Set rule, the argument it matched, and Settings > Inspection > WAF policy shows the file, switches the WAF between blocking and logging only, and removes exceptions. Both go through the backend, which edits the same file through the API (/api/waf/policy, see the API reference). It sends every new version to the WAF's POST /policy/validate first and writes nothing the WAF refuses, or when the WAF cannot be reached; it then writes the file atomically, readable by the WAF's user, asks the WAF to reload it, and records the change in the audit log. A file edited by hand stays the reference: a PUT must carry the digest of the file it was edited from, and a file changed since is refused. The waf and backend services must read the same WAF_POLICY_FILE; the compose files pass it to both.

SSL bump CA (HTTPS inspection) ​

If config/ssl_cert.pem or ssl/ssl_key.pem is missing, the proxy generates an RSA 2048 CA valid for 825 days at start. The key is in ssl/, which only the proxy mounts. To use your own CA, or to rotate this one, follow Rotating the SSL-bump CA.

Clients must trust the CA while HTTPS inspection is on. Download it from Settings > Inspection > Download the CA certificate (.pem), which also shows these steps:

PlatformSteps (from the folder with secure-proxy-ca.pem)
WindowsAdministrator prompt: certutil -addstore -f Root secure-proxy-ca.pem
macOSsudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain secure-proxy-ca.pem
Debian, Ubuntusudo cp secure-proxy-ca.pem /usr/local/share/ca-certificates/secure-proxy-ca.crt && sudo update-ca-certificates (the name must end in .crt)
Fedora, RHELsudo cp secure-proxy-ca.pem /etc/pki/ca-trust/source/anchors/ && sudo update-ca-trust
iOS, iPadOSOpen the file on the device, install it in Settings, General, VPN and Device Management, then turn it on in Settings, General, About, Certificate Trust Settings
AndroidSettings, Security, Encryption and credentials, Install a certificate, CA certificate. Apps built for Android 7 or later do not trust a user-installed CA
FirefoxSettings, Privacy and Security, Certificates, View Certificates, Authorities, Import

Traffic on port 443 that is not TLS ​

With HTTPS inspection on, the proxy reads the TLS handshake of every CONNECT before it decrypts the connection. When what comes through is not TLS, the proxy accepts the CONNECT with 200 and then closes the connection: there is nothing it could decrypt and inspect. SSH through the proxy to port 443 (ssh.github.com:443, for example) is one such case.

To let such connections through uninspected, add this line to /config/custom_squid_extra.conf:

on_unsupported_protocol tunnel all

The data in those tunnels then reaches its destination without passing through the WAF.

A protocol in which the server speaks first (SMTP, FTP, IMAP) waits forever either way: the proxy is waiting for a handshake from the client that never comes.

TLS server names without decrypting ​

Settings > Inspection > Read TLS server names without decrypting (tls_peek_enabled, off by default) makes the proxy read the server name each client puts in its TLS handshake. The proxy then lets the connection through still encrypted. Clients need no certificate.

  • The WAF receives the name and checks it: a name other than the host the client gave in its CONNECT scores 5 (TLS-SNI-001), and the SSRF rules and the DGA and typosquat checks read it. Such a mismatch blocks only together with one of those checks.
  • A connection whose first bytes are not TLS (SSH to port 443) passes, uninspected, and the WAF counts it.
  • /metrics on the WAF counts the handshakes read (waf_tls_handshakes_total), those that were not TLS (waf_tls_not_tls_total) and the mismatches (waf_tls_sni_mismatch_total).
  • The WAF also takes the JA4 fingerprint of each handshake, with no score, and logs the first time a client shows one (WAF TLS-JA4); see Architecture.
  • With HTTPS inspection on, the proxy decrypts instead, and this setting has no effect.

The access log keeps one line per connection: the tunnel, with its bytes, or the WAF's 403. Two cases differ:

  • A CONNECT that carries no Host header (some HTTP/1.0 clients) is logged twice.
  • A connection on which the client never sends a byte is not logged. This is what happens with a protocol in which the server speaks first (SMTP, FTP, IMAP): it waits forever, as it does under HTTPS inspection.

Released under the MIT License. · Privacy & legal